Files
pl_mail/truenas.compose.yaml
T
09c503c61c Sign in through an OpenID Connect provider (Authentik, Keycloak…) (#106)
plMail could only sign people in with a local password. On an install that
already runs Authentik or Keycloak, that is one more account and password
per person, outside the provider's access policy.

A second authenticator on the `main` firewall, beside the password form.
Both end in the same token for the same User, so the user checker, the login
throttle and TOTP two-factor apply unchanged. Authorization code flow with
PKCE, state and nonce on league/oauth2-client's GenericProvider; endpoints
come from the provider's discovery document.

- Set up under a new Admin → SSO tab; the OIDC_* variables are the fallback
  for an install configured from its compose file, and a saved provider wins
  whole. This lets an administrator who points it at a provider of their own
  sign in as any account without a second factor; the security model says so.
- The ID token's signature is not verified: it only ever comes from the
  token endpoint over TLS (OIDC Core § 3.1.3.7 step 6). So the issuer and
  endpoints must be https and no request to the provider follows a redirect.
  Accepting an ID token from anywhere else needs JWKS verification first.
- An account is linked by (issuer, sub). The address is used for the first
  link only, and only when the provider reports it verified.
- Auto-creation is off by default and creates an ordinary account with no
  usable password.
- app:user:oidc-unlink forgets a link from the console, for somebody deleted
  and re-added at the provider.

Migration: two nullable columns and a unique index on `user`, and a one-row
table `oidc_config`. Nothing backfilled; reversible.

Not run: Playwright on this branch. No automated test makes a round trip
against a real provider; that was checked by hand on a local test stack.

Closes #91

Co-Authored-By: Paul Lützner <mail@pluetzner.de>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:17:00 +02:00

330 lines
16 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 ""
# -- Single sign-on (Authentik, Keycloak, any OpenID Connect provider) --
# Easier to set up from inside plMail: Admin → SSO shows the redirect URI to
# paste into the provider's console, and anything saved there wins over
# what is here, so most installs can leave this block alone. The issuer is
# the provider's issuer URL and must be https. See docs/providers/oidc.md.
oidc_issuer: &oidc_issuer ""
oidc_client_id: &oidc_client_id ""
oidc_client_secret: &oidc_client_secret ""
# The provider's name on the login button, e.g. "Authentik".
oidc_button_label: &oidc_button_label ""
# ─────────────────────────────────────────────────────────────────────────────
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
OIDC_ISSUER: *oidc_issuer
OIDC_CLIENT_ID: *oidc_client_id
OIDC_CLIENT_SECRET: *oidc_client_secret
OIDC_BUTTON_LABEL: *oidc_button_label
TRUSTED_PROXIES:
services:
# Mints whatever was left blank above into the storage directory, then exits. Runs
# before everything else: Postgres reads its password when its container is
# created and cannot wait for the app to hand it 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 70 in the alpine image (999 is the Debian one) — 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 70:70 /app/var/data/postgres
# Postgres re-execs as uid 70 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 70. Execute-only: grants
# traversal, not the ability to list or read anything.
chmod o+x /app/var/data || setfacl -m u:70:--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
# Everything that is not the web server, in one container: the IMAP
# supervisor, one consumer per queue, the scheduler and the Mercure hub —
# ten processes, kept running by `app:work`. They used to be seven
# containers. Still one PROCESS per queue, so a send never waits behind a
# sync; only the containers merged.
#
# Answers to `mercure` on the network because it runs the hub, so the web
# container's proxy and MERCURE_URL reach it where they always reached the
# hub container.
worker:
image: *image
pull_policy: always
restart: unless-stopped
command: php bin/console app:work
# Reaps the IMAP supervisor's children should it die hard.
init: true
# Longer than app:work's own 25s, which it gives every process to
# finish the message in hand.
stop_grace_period: 30s
environment:
<<: *app-env
APP_CONTAINER_NAME: worker
networks:
default:
aliases: [mercure]
volumes:
# One mount for everything plMail writes, as on php.
- type: bind
source: *path_data
target: /app/var/data
bind:
create_host_path: true
# The hub's state, in the volumes the hub container used.
- type: volume
source: mercure_data
target: /data
- type: volume
source: mercure_config
target: /config
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"]
# STATED, because the default is what made every start slow. Docker
# runs the first probe one `interval` after the container starts, and
# the default interval is thirty seconds — so the web server and the
# workers, which wait for this to be healthy, could not begin for
# half a minute however quickly Postgres was up, and for a whole
# minute whenever it was still replaying its log at the first look.
# A start that should take ten seconds took forty to seventy.
# pg_isready is a connection attempt; every five seconds costs nothing.
interval: 5s
timeout: 5s
# Thirty of them, so that a database in trouble is called unhealthy
# after the same two and a half minutes as before the interval changed.
retries: 30
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"
volumes:
caddy_data:
caddy_config:
mercure_data:
mercure_config: