mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 10:04:18 +00:00
Three faults, one story: the stream could die silently, nothing told the
user, and nothing tested any of it.
**It did not recover.** EventSource retries on its own only when a
connection drops cleanly. An HTTP error response is fatal per spec —
readyState goes to CLOSED and the browser never tries again — and our
Caddy answers 502 for the seconds a hub restart takes. Measured: an open
stream, a 20s outage, and the tab stayed dead after the hub was back.
Every deploy did this to every open tab. There is now a reconnect loop
with jittered backoff, plus an immediate retry when a tab is refocused
or the network returns.
**Nothing said so.** The mail list has no polling fallback, so a dead
stream looks exactly like a quiet mailbox — the inbox stops changing and
nothing on screen explains why. A glow behind the logo now carries the
state, quiet when healthy, and announced rather than colour-only. While
the stream is down the list falls back to a 60s poll: strictly the worse
mechanism, which is why it runs only when the good one is unavailable.
**The hub accepted anyone.** dev.Caddyfile was in use in every
environment including TrueNAS, and it sets `anonymous`. Since
/.well-known/mercure* is proxied publicly, anyone who could reach an
instance could subscribe to mail/user/<any id> and watch that account's
sync activity. Both stacks now run the image's default config, which
requires the subscriber JWT.
That JWT expires — an hour by default, well inside a remember-me
session — and the hub only checks it when a connection is made, so the
expiry is invisible until the next reconnect is refused. /mercure/auth
re-issues the cookie, and the client refreshes it before every retry.
The cookie is attached by a response subscriber rather than by
`mercure(topics, {subscribe: …})` in the layout, because that helper
throws for a hub on another second-level domain and a throw in a
template takes the whole page with it. PHPUnit caught it at once: its
client requests as localhost against a 127.0.0.1 hub, and every
page-rendering test turned 500. An external hub is a legitimate
deployment; it should cost live updates, not the page.
Tests: compose.test.yaml grew a real hub, running the same non-anonymous
config as production so the suite exercises the authorization path
instead of one that accepts anything. The recovery spec stops the
container — `setOffline` does not sever an established SSE connection
and route interception never fires without a request, so both looked
like passing tests of nothing. It lives in the exclusive project: there
is one hub, and taking it away mid-run breaks specs that never asked.
Verified by disabling the reconnect and watching the recovery test fail,
then restoring it and watching it pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
372 lines
16 KiB
YAML
372 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.
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
# -- 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 ""
|
|
|
|
# -- Image & host port --
|
|
image: &image ghcr.io/karatektus/pl_mail:latest # pin a real tag instead of :latest for deterministic updates
|
|
http_port: &http_port 30080
|
|
|
|
# -- 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
|
|
|
|
messenger-worker:
|
|
image: *image
|
|
pull_policy: always
|
|
restart: unless-stopped
|
|
command: php bin/console messenger:consume async --time-limit=3600 --memory-limit=256M
|
|
stop_grace_period: 15s
|
|
environment:
|
|
<<: *app-env
|
|
APP_CONTAINER_NAME: messenger-worker
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
healthcheck:
|
|
test: ["CMD", "curl", "-f", "http://localhost/healthz"]
|
|
timeout: 5s
|
|
retries: 5
|
|
start_period: 60s
|
|
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:
|