mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 10:04:18 +00:00
v0.2.31 could not mint a token at all on a TrueNAS install, and nothing an operator set would have fixed it. That release bound `iss` and `aud` to MERCURE_PUBLIC_URL. truenas.compose.yaml leaves that BLANK ON PURPOSE — the setup screen asks for the public URL on first boot and stores it in the database, which is long after the hub container has had to be told what to trust. An empty issuer is not a weak issuer: the factory refuses it outright with `The "iss" additional claim is required by RFC 9068 access tokens`, so there was no subscriber cookie, every publish threw, and the symptom was a red dot indistinguishable from a hub that was simply down. The mistake underneath was deriving a value that must exist at CONTAINER START from one that only exists after SETUP. So it is a literal now, shared by the application and every compose file, and no installation has to know it. An identifier does not have to resolve — `.invalid` is reserved by RFC 2606 for exactly this — and all that matters is that both halves agree on the same string, which a literal guarantees and a derived value cannot. The same string everywhere costs nothing: the signing secret is generated per installation, so one install's token has never been usable against another's hub however it identifies itself. truenas.compose.yaml had been left behind twice more. It still carried the "/healthz" check that v0.2.30 replaced in the other two files, so its hub would have gone unhealthy the moment the image moved; and it had no pull_policy, so the image never moved at all. That is the combination that produced the original report: an application on the 1.0 protocol talking to a 0.x hub that nothing had ever pulled. The regression test empties MERCURE_PUBLIC_URL rather than reading the config, because what broke was the derivation — a test that read the same placeholder the code did would have passed throughout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
425 lines
18 KiB
YAML
425 lines
18 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
|
|
# PULLED ON EVERY `up`, which is the only thing that makes "unpinned"
|
|
# actually mean "keeps itself current".
|
|
#
|
|
# Without this the tag floats and the image does not: compose reuses
|
|
# whatever is already on the box, so a hub can sit years behind the tag it
|
|
# claims to be while `docker compose up -d` reports success. v0.2.31 is what
|
|
# that costs — plMail moved to the Mercure 1.0 protocol, every install whose
|
|
# hub had not been pulled was left speaking 1.0 to a 0.x hub, and live
|
|
# updates stopped with nothing in the application's own logs to say why.
|
|
#
|
|
# The cost of the check is one registry round trip per `up` that answers
|
|
# "same digest" almost every time. The cost of not doing it is the paragraph
|
|
# above.
|
|
pull_policy: always
|
|
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:-}
|
|
# The two halves of RFC 9068: the issuer this hub accepts, and the
|
|
# audience it demands. Both are the literal from services.yaml —
|
|
# `mercure.identifier` — which is where the reasoning lives. In short: a
|
|
# hub is configured at container start, and an installation's own URL is
|
|
# not always known by then, so these cannot be derived from it.
|
|
#
|
|
# resource_identifier has to be PINNED whatever value is chosen. Left
|
|
# alone the hub works the audience out from each request, and plMail
|
|
# arrives at it by two different roads — the browser subscribes at the
|
|
# public URL, the application publishes to the internal one — so a hub
|
|
# deriving its own answer would refuse every publish.
|
|
MERCURE_TRUSTED_ISSUERS: https://plmail.invalid/.well-known/mercure
|
|
MERCURE_EXTRA_DIRECTIVES: resource_identifier https://plmail.invalid/.well-known/mercure
|
|
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
|
|
# NO `test:` HERE ON PURPOSE — the image's own HEALTHCHECK is used.
|
|
#
|
|
# We used to spell one out, aimed at "/healthz", and the hub moved its
|
|
# readiness probe out from under it: "/healthz" answers 404 now, `curl
|
|
# -f` reads a 404 as failure, and this container reported itself unhealthy
|
|
# for ever while serving every subscription put to it. The image is
|
|
# deliberately on a floating tag so it keeps up to date by itself, which
|
|
# means anything we hard-code about its internals is a thing waiting to
|
|
# rot. Its own check — wget against 127.0.0.1:2019/mercure/health/ready,
|
|
# 60s grace, 5 retries — moves when the image moves, which is the whole
|
|
# point of not pinning it.
|
|
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:
|