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>
504 lines
23 KiB
YAML
504 lines
23 KiB
YAML
# plMail — TrueNAS SCALE custom app (Apps > Discover > ⋮ > Install via YAML)
|
|
#
|
|
# TrueNAS's YAML installer does NOT support ${VAR} interpolation, so all config is
|
|
# hoisted into the x-config block below and aliased (*name) into the services.
|
|
# Edit that block; nothing below it needs touching.
|
|
#
|
|
# You do NOT need to create any directories by hand. Every bind below carries
|
|
# create_host_path, so Docker makes them on first start. Point them at a dataset
|
|
# rather than a plain directory if you want ZFS snapshots of your mail.
|
|
|
|
name: plmail
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# 1. REQUIRED — the only thing to configure. Everything else works as-is.
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
x-config:
|
|
# -- Storage --
|
|
# One directory. plMail makes its own subfolders inside it — postgres,
|
|
# attachments, raw, uploads, secrets — so there is nothing to create and
|
|
# nothing else to point anywhere.
|
|
#
|
|
# This is the whole install. Back it up, or make it a dataset and snapshot
|
|
# it: the secrets subfolder holds the key every mailbox password is
|
|
# encrypted with, and without it they are gone.
|
|
path_data: &path_data /mnt/yourPool/yourDataSet/plmail
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# 2. OPTIONAL — every one of these works left exactly as it is.
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
# -- Which builds you get --
|
|
# The tag on the image decides how often this install changes, and it is
|
|
# the one optional setting worth deciding on deliberately:
|
|
#
|
|
# :latest the most recent tagged release. Moves only when a version is
|
|
# released. The default, and what most installs want.
|
|
# :main every merge into the default branch, released or not. Follow
|
|
# this to run what is being worked on without waiting for a
|
|
# version to be tagged — it can break, and the changelog will
|
|
# not describe it yet.
|
|
# :0.0.14 one release, pinned. Nothing changes until you edit this.
|
|
# No leading "v" — the git tag is v0.0.14, the image tag is not.
|
|
# :0.0 the newest patch of one minor line.
|
|
# :sha-1a2b3c one commit, pinned exactly.
|
|
#
|
|
# Every service pulls on start (pull_policy: always), so a redeploy from the
|
|
# TrueNAS app page is what picks up a newer build of whichever tag is set.
|
|
# Downgrading is not supported: migrations run forward on boot, so moving
|
|
# from :main back to :latest can leave the database ahead of the code.
|
|
image: &image ghcr.io/karatektus/pl_mail:latest
|
|
http_port: &http_port 30080
|
|
|
|
# -- Public address --
|
|
# LEAVE BLANK. The setup screen asks for the public URL on first boot and
|
|
# stores it; the Mercure hub URL is then derived from it automatically
|
|
# (the hub is proxied on the same origin, at /.well-known/mercure).
|
|
# Fill these in only to pin values from the environment — anything set
|
|
# here wins over what the setup screen stored.
|
|
app_public_url: &app_public_url ""
|
|
mercure_public_url: &mercure_public_url ""
|
|
|
|
# -- Secrets --
|
|
# LEAVE THESE BLANK. All four are generated on first start into the storage
|
|
# directory above. Fill one in only if you manage secrets yourself; nothing is ever
|
|
# generated over the top of a value you supplied.
|
|
#
|
|
# app_encryption_key encrypts every mailbox password and OAuth token at rest.
|
|
# Changing it once mail accounts exist makes the app refuse to start rather
|
|
# than overwrite what it can no longer read — see CONTRIBUTING.md.
|
|
app_secret: &app_secret ""
|
|
app_encryption_key: &app_encryption_key ""
|
|
db_password: &db_password ""
|
|
mercure_jwt: &mercure_jwt ""
|
|
|
|
# Leave blank and the app assembles it from the generated db_password. Set it
|
|
# only to point plMail at a database of your own — and then the password
|
|
# inside it MUST equal db_password (an anchor cannot be spliced mid-string):
|
|
database_url: &database_url ""
|
|
|
|
# -- Web Push (browser/PWA notifications + JMAP PushSubscription) --
|
|
# Generated on first start if left blank. Rotating them invalidates every
|
|
# existing subscription; every device has to re-enable notifications.
|
|
# vapid_subject must be a mailto: or https: URL identifying you (RFC 8292).
|
|
vapid_subject: &vapid_subject "mailto:you@yourdomain.com"
|
|
vapid_public_key: &vapid_public_key ""
|
|
vapid_private_key: &vapid_private_key ""
|
|
|
|
# -- Gmail and Outlook sign-in --
|
|
# These are easier to set up from inside plMail: Admin → Integrations walks
|
|
# through it and shows you the redirect URI to paste into the provider's
|
|
# console. Anything entered there is stored in the database and wins over
|
|
# what is here, so most installs can leave this whole block alone.
|
|
google_client_id: &google_client_id ""
|
|
google_client_secret: &google_client_secret ""
|
|
gmail_pubsub_topic: &gmail_pubsub_topic "projects/your-project-id/topics/gmail-push"
|
|
# Shared secret appended to the Pub/Sub push endpoint as ?token=… — must
|
|
# match the push subscription in Google Cloud or every push is rejected.
|
|
gmail_pubsub_token: &gmail_pubsub_token ""
|
|
|
|
graph_client_id: &graph_client_id ""
|
|
graph_client_secret: &graph_client_secret ""
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
x-app-env: &app-env
|
|
APP_ENV: prod
|
|
APP_PUBLIC_URL: *app_public_url
|
|
APP_SECRET: *app_secret
|
|
APP_ENCRYPTION_KEY: *app_encryption_key
|
|
DATABASE_URL: *database_url
|
|
POSTGRES_USER: app
|
|
POSTGRES_DB: app
|
|
# Everything plMail writes goes under the single mounted directory.
|
|
APP_STORAGE_DIR: var/data
|
|
APP_SECRETS_DIR: /app/var/data/secrets
|
|
APP_SECRETS_FILE: /app/var/data/secrets/generated.env
|
|
MESSENGER_TRANSPORT_DSN: "doctrine://default?auto_setup=0"
|
|
MERCURE_URL: "http://mercure/.well-known/mercure"
|
|
MERCURE_PUBLIC_URL: *mercure_public_url
|
|
VAPID_SUBJECT: *vapid_subject
|
|
VAPID_PUBLIC_KEY: *vapid_public_key
|
|
VAPID_PRIVATE_KEY: *vapid_private_key
|
|
MERCURE_JWT_SECRET: *mercure_jwt
|
|
GOOGLE_OAUTH_CLIENT_ID: *google_client_id
|
|
GOOGLE_OAUTH_CLIENT_SECRET: *google_client_secret
|
|
GMAIL_PUBSUB_TOPIC: *gmail_pubsub_topic
|
|
GMAIL_PUBSUB_VERIFICATION_TOKEN: *gmail_pubsub_token
|
|
MICROSOFT_OAUTH_CLIENT_ID: *graph_client_id
|
|
MICROSOFT_OAUTH_CLIENT_SECRET: *graph_client_secret
|
|
MICROSOFT_OAUTH_TENANT: common
|
|
TRUSTED_PROXIES:
|
|
|
|
services:
|
|
# Mints whatever was left blank above into the storage directory, then exits. Runs
|
|
# before everything else: Postgres and Mercure read their secrets when their
|
|
# containers are created and cannot wait for the app to hand them one.
|
|
# Idempotent — on every later start it finds the file and does nothing.
|
|
secrets-init:
|
|
image: *image
|
|
pull_policy: always
|
|
restart: "no"
|
|
# Lays out the shared directory before anything else starts, then mints
|
|
# the secrets. Docker creates the host path as root, and Postgres runs
|
|
# as uid 999 in the 18-alpine image (uid 70 before 18) — it cannot make
|
|
# its own data directory inside a root-owned parent, which is the one
|
|
# thing "subfolders are automatic" cannot leave to the services
|
|
# themselves.
|
|
entrypoint:
|
|
- /bin/sh
|
|
- -c
|
|
- |
|
|
set -eu
|
|
mkdir -p /app/var/data/secrets \
|
|
/app/var/data/postgres \
|
|
/app/var/data/attachments \
|
|
/app/var/data/raw \
|
|
/app/var/data/uploads
|
|
chown -R 999:999 /app/var/data/postgres
|
|
# Postgres re-execs as uid 999 and must be able to traverse the
|
|
# mount root, but a TrueNAS dataset with the "Apps" preset is
|
|
# 770 apps:apps — no x-bit for uid 999. Execute-only: grants
|
|
# traversal, not the ability to list or read anything.
|
|
chmod o+x /app/var/data || setfacl -m u:999:--x /app/var/data || true
|
|
exec generate-secrets
|
|
environment:
|
|
APP_SECRETS_DIR: /app/var/data/secrets
|
|
APP_SECRET: *app_secret
|
|
APP_ENCRYPTION_KEY: *app_encryption_key
|
|
POSTGRES_PASSWORD: *db_password
|
|
MERCURE_JWT_SECRET: *mercure_jwt
|
|
volumes:
|
|
- type: bind
|
|
source: *path_data
|
|
target: /app/var/data
|
|
bind:
|
|
create_host_path: true
|
|
|
|
php:
|
|
image: *image
|
|
pull_policy: always
|
|
restart: unless-stopped
|
|
environment:
|
|
<<: *app-env
|
|
# ":80" = FrankenPHP serves plain HTTP; terminate TLS at your reverse proxy (recommended).
|
|
# For a domain forwarded straight to :443 here, set this to that hostname and re-add the 443 ports.
|
|
SERVER_NAME: ":80"
|
|
MAILER_DSN: "null://null"
|
|
APP_CONTAINER_NAME: web
|
|
ports:
|
|
- target: 80
|
|
published: *http_port
|
|
protocol: tcp
|
|
volumes:
|
|
# Named volumes rather than host paths: these hold TLS material
|
|
# Caddy regenerates by itself, so there is nothing here worth a
|
|
# dataset of its own — and two fewer paths to get right.
|
|
- type: volume
|
|
source: caddy_data
|
|
target: /data
|
|
- type: volume
|
|
source: caddy_config
|
|
target: /config
|
|
# One mount for everything plMail writes. APP_STORAGE_DIR and
|
|
# APP_SECRETS_* point inside it, so attachments, raw messages,
|
|
# uploads and the generated secrets all land here — and every
|
|
# service sees the same files, which the encryption key depends on.
|
|
- type: bind
|
|
source: *path_data
|
|
target: /app/var/data
|
|
bind:
|
|
create_host_path: true
|
|
depends_on:
|
|
secrets-init:
|
|
condition: service_completed_successfully
|
|
database:
|
|
condition: service_healthy
|
|
|
|
imap-supervisor:
|
|
image: *image
|
|
pull_policy: always
|
|
restart: unless-stopped
|
|
command: php bin/console app:imap:supervise
|
|
stop_grace_period: 30s
|
|
environment:
|
|
<<: *app-env
|
|
APP_CONTAINER_NAME: imap-supervisor
|
|
volumes:
|
|
# One mount for everything plMail writes. APP_STORAGE_DIR and
|
|
# APP_SECRETS_* point inside it, so attachments, raw messages,
|
|
# uploads and the generated secrets all land here — and every
|
|
# service sees the same files, which the encryption key depends on.
|
|
- type: bind
|
|
source: *path_data
|
|
target: /app/var/data
|
|
bind:
|
|
create_host_path: true
|
|
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: a worker already
|
|
# inside a long handler cannot pick up anything else however the queues are
|
|
# prioritised, which is why pressing Send used to wait for a Gmail batch.
|
|
#
|
|
# Anything leaving plMail, and the only queue somebody is watching.
|
|
worker-export:
|
|
image: *image
|
|
pull_policy: always
|
|
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:
|
|
# One mount for everything plMail writes. APP_STORAGE_DIR and
|
|
# APP_SECRETS_* point inside it, so attachments, raw messages,
|
|
# uploads and the generated secrets all land here — and every
|
|
# service sees the same files, which the encryption key depends on.
|
|
- type: bind
|
|
source: *path_data
|
|
target: /app/var/data
|
|
bind:
|
|
create_host_path: true
|
|
healthcheck:
|
|
disable: true
|
|
depends_on:
|
|
secrets-init:
|
|
condition: service_completed_successfully
|
|
database:
|
|
condition: service_healthy
|
|
|
|
# Runs the scheduled jobs defined in
|
|
# App\Infrastructure\Scheduler\MaintenanceSchedule: polling sync, push
|
|
# renewal, monitoring pruning and the blob sweep. Drop this service and
|
|
# none of them ever fire.
|
|
|
|
# Mail arriving and the work that immediately follows it.
|
|
worker-ingest:
|
|
image: *image
|
|
pull_policy: always
|
|
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:
|
|
# One mount for everything plMail writes. APP_STORAGE_DIR and
|
|
# APP_SECRETS_* point inside it, so attachments, raw messages,
|
|
# uploads and the generated secrets all land here — and every
|
|
# service sees the same files, which the encryption key depends on.
|
|
- type: bind
|
|
source: *path_data
|
|
target: /app/var/data
|
|
bind:
|
|
create_host_path: true
|
|
healthcheck:
|
|
disable: true
|
|
depends_on:
|
|
secrets-init:
|
|
condition: service_completed_successfully
|
|
database:
|
|
condition: service_healthy
|
|
|
|
# Runs the scheduled jobs defined in
|
|
# App\Infrastructure\Scheduler\MaintenanceSchedule: polling sync, push
|
|
# renewal, monitoring pruning and the blob sweep. Drop this service and
|
|
# none of them ever fire.
|
|
|
|
# Bulk and deliberate. Also drains `async`, the pre-split queue, so
|
|
# envelopes written before the upgrade still have a consumer. Nothing routes
|
|
# there any more; drop it once a release has passed.
|
|
worker-maintenance:
|
|
image: *image
|
|
pull_policy: always
|
|
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:
|
|
# One mount for everything plMail writes. APP_STORAGE_DIR and
|
|
# APP_SECRETS_* point inside it, so attachments, raw messages,
|
|
# uploads and the generated secrets all land here — and every
|
|
# service sees the same files, which the encryption key depends on.
|
|
- type: bind
|
|
source: *path_data
|
|
target: /app/var/data
|
|
bind:
|
|
create_host_path: true
|
|
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: *image
|
|
pull_policy: always
|
|
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:
|
|
- type: bind
|
|
source: *path_data
|
|
target: /app/var/data
|
|
bind:
|
|
create_host_path: true
|
|
healthcheck:
|
|
disable: true
|
|
depends_on:
|
|
secrets-init:
|
|
condition: service_completed_successfully
|
|
database:
|
|
condition: service_healthy
|
|
|
|
# Runs the scheduled jobs defined in
|
|
# App\Infrastructure\Scheduler\MaintenanceSchedule: polling sync, push
|
|
# renewal, monitoring pruning and the blob sweep. Drop this service and
|
|
# none of them ever fire.
|
|
scheduler:
|
|
image: *image
|
|
pull_policy: always
|
|
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:prune:blobs unlinks files across all three stores, so it
|
|
# needs the same binds as the workers that write them.
|
|
- type: bind
|
|
source: *path_data
|
|
target: /app/var/data
|
|
bind:
|
|
create_host_path: true
|
|
healthcheck:
|
|
disable: true
|
|
depends_on:
|
|
secrets-init:
|
|
condition: service_completed_successfully
|
|
database:
|
|
condition: service_healthy
|
|
|
|
database:
|
|
image: postgres:18-alpine
|
|
restart: unless-stopped
|
|
environment:
|
|
POSTGRES_DB: app
|
|
POSTGRES_USER: app
|
|
# Only the _FILE form: the image rejects both being set at once, and
|
|
# the file is written with whatever db_password holds — generated or
|
|
# yours — so it is always the effective password.
|
|
POSTGRES_PASSWORD_FILE: /plmail/secrets/postgres_password
|
|
PGDATA: /plmail/postgres
|
|
healthcheck:
|
|
test: ["CMD", "pg_isready", "-d", "app", "-U", "app"]
|
|
timeout: 5s
|
|
retries: 5
|
|
start_period: 60s
|
|
depends_on:
|
|
secrets-init:
|
|
condition: service_completed_successfully
|
|
volumes:
|
|
# PGDATA is a subfolder of the shared mount, so Postgres creates
|
|
# and owns it on first start — nothing to chown by hand.
|
|
- type: bind
|
|
source: *path_data
|
|
target: /plmail
|
|
bind:
|
|
create_host_path: true
|
|
command:
|
|
- "postgres"
|
|
- "-c"
|
|
- "shared_preload_libraries=pg_stat_statements"
|
|
- "-c"
|
|
- "pg_stat_statements.track=top"
|
|
|
|
mercure:
|
|
image: dunglas/mercure
|
|
# PULLED ON EVERY `up`. 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 the update reports success.
|
|
# That is how an install ended up running a 0.x hub against an
|
|
# application that had moved to the 1.0 protocol.
|
|
pull_policy: always
|
|
restart: unless-stopped
|
|
environment:
|
|
SERVER_NAME: ':80'
|
|
# The hub image has no _FILE support, so the entrypoint below reads
|
|
# the generated secret in a shell before exec'ing Caddy. Filling in
|
|
# mercure_jwt above wins: the generator never overwrites it, so both
|
|
# sides still read the same value.
|
|
MERCURE_JWT_SECRET: *mercure_jwt
|
|
# The issuer this hub accepts and the audience it demands. The
|
|
# literal from config/services.yaml (`mercure.identifier`), NOT this
|
|
# install's own address — mercure_public_url above is blank on
|
|
# purpose here, because the setup screen stores the real one on
|
|
# first boot, and a hub cannot wait until then to be configured.
|
|
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 /plmail/secrets/generated.env ]; then
|
|
MERCURE_JWT_SECRET="$$(sed -n 's/^MERCURE_JWT_SECRET=//p' /plmail/secrets/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 — this is a
|
|
# production stack and the dev one turns on `anonymous`, which accepts
|
|
# subscribers with no JWT at all. Since the app proxies
|
|
# /.well-known/mercure* publicly, that let anyone who could reach this
|
|
# instance subscribe to mail/user/<any id> and watch that account sync.
|
|
# The default config requires the subscriber JWT the app now mints.
|
|
command: /usr/bin/caddy run --config /etc/caddy/Caddyfile
|
|
depends_on:
|
|
secrets-init:
|
|
condition: service_completed_successfully
|
|
# NO healthcheck here, deliberately — the image's own is used. The one
|
|
# that used to be here asked for "/healthz", which the hub moved: it
|
|
# answers 404 now, `curl -f` reads that as failure, and the container
|
|
# reported itself unhealthy for ever while serving perfectly.
|
|
volumes:
|
|
- type: bind
|
|
source: *path_data
|
|
target: /plmail
|
|
bind:
|
|
create_host_path: true
|
|
read_only: true
|
|
- type: volume
|
|
source: mercure_data
|
|
target: /data
|
|
- type: volume
|
|
source: mercure_config
|
|
target: /config
|
|
|
|
volumes:
|
|
caddy_data:
|
|
caddy_config:
|
|
mercure_data:
|
|
mercure_config:
|