mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
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>
351 lines
14 KiB
YAML
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:
|