Files
webmail-client/docker/entrypoint.sh
T
lukasandClaude Opus 5 95ca1b49f6 Antispam plugin (M7 phase C1): classify, file, and stay out of the way
Second-pass spam filtering per SPEC Appendix C. The engine is Rspamd in its own
container; this plugin is glue and policy, never a spam engine of its own.

Classification (§C.4) hooks message.new, acts only on inbox arrivals of enabled
accounts, and runs deny list -> allow list -> engine -> thresholds. Every
failure path ends in "deliver normally, unclassified": an unreachable engine, a
missing blob, an account with no Junk folder, a failed verdict write. Nothing
here may delay or lose mail, so no service declares depends_on: rspamd.

Two containers, started with everything else rather than behind a profile:
rspamd, and a redis of its own. The separate redis is not incidental — the app's
redis holds sync leases and arq queues under noeviction, and an ever-growing
Bayes corpus must never be able to fail a job enqueue. This one takes RDB
snapshots and evicts, because its data is derived and retrainable.

Network checks (RBL/SURBL/fuzzy) are off: they leak message-derived hashes and
URLs to third parties, which a self-hosted server's users did not ask for. The
engine makes no outbound connections in the default configuration.

The controller password is generated into .env like MASTER_KEY. It is internal;
humans will reach Rspamd through the admin panel, authenticated as the ordinary
administrator, rather than typing it.

local.d/groups.conf zeroes six SMTP-time symbols — DATE_IN_PAST,
PREVIOUSLY_DELIVERED, HFILTER_HOSTNAME_UNKNOWN, RCVD_NO_TLS_LAST, ARC_NA,
DMARC_NA — and greylisting is disabled. Measured against 398 real delivered
messages, those checks were firing on 44-71% of ordinary mail and pushing 11.3%
of it over the banner threshold; scoring mail at rest for facts true of every
delivered message is simply the wrong question. After: median 3.09 -> 0.70,
p95 7.00 -> 3.40, banner rate 11.3% -> 1.0%. The spec's 12.0/6.0 thresholds were
never the problem and are unchanged. Symbols are zeroed rather than removed so
verdicts stay explainable, and they matter again at the M6 edge (§C.7).

Still to come in C1: the §C.6 router and UI slots. C2 adds the learning loop —
until Bayes is trained, genuine scams and legitimate bulk mail overlap in the
5-8 band and no threshold separates them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
2026-09-16 22:14:43 +02:00

122 lines
4.5 KiB
Bash
Executable File

#!/bin/sh
# One image, several roles. Called as: entrypoint <command> [args...]
#
# api uvicorn, behind the `web` nginx container
# worker-sync lease-owned account sync supervisor (SPEC §6.3)
# worker-jobs arq consumer (interactive + bulk queues)
# worker-all both in one process (small installs; halves RAM use)
# migrate core Alembic upgrade + per-plugin install; exits 0 when done
# reindex rebuild the search index from Postgres
# plugins `python -m app.plugins.cli ...` passthrough
# shell /bin/sh (debugging)
#
# Any other argument is exec'd verbatim, so `docker compose run api python -c ...`
# still works.
set -eu
: "${API_HOST:=0.0.0.0}"
: "${API_PORT:=8000}"
: "${API_WORKERS:=1}"
# Plugins whose migrations are applied on every `migrate` run. Space-separated so
# an operator can add a third-party plugin without editing the image.
: "${ENABLED_PLUGINS:=calendar contacts labels filters antispam}"
cmd="${1:-api}"
[ "$#" -gt 0 ] && shift || true
# Belt and braces on top of the compose healthcheck: a NAS with slow disks can
# take a while over initdb, and `migrate` is a one-shot with no restart policy —
# if it starts a moment early it fails the whole bring-up.
wait_for_db() {
python - <<'PYEOF'
import os, socket, sys, time
from urllib.parse import urlparse
url = os.environ.get("DATABASE_URL", "")
parsed = urlparse(url.replace("+asyncpg", ""))
host = parsed.hostname or "postgres"
port = parsed.port or 5432
deadline = time.time() + float(os.environ.get("DB_WAIT_SECONDS", "180"))
# Report WHY it is not connecting, not just that it isn't: the first failure
# immediately, then every 30s. A silent retry loop that ends in "never accepted"
# tells whoever is installing this precisely nothing.
last_error = None
next_report = 0.0
while time.time() < deadline:
try:
with socket.create_connection((host, port), timeout=3):
print(f"[entrypoint] database accepting connections at {host}:{port}")
sys.exit(0)
except OSError as exc:
last_error = exc
if time.time() >= next_report:
try:
resolved = socket.gethostbyname(host)
except OSError as dns_exc:
resolved = f"UNRESOLVED ({dns_exc})"
print(
f"[entrypoint] waiting for {host}:{port} "
f"(resolves to {resolved}): {type(exc).__name__}: {exc}",
flush=True,
)
next_report = time.time() + 30
time.sleep(2)
print(
f"[entrypoint] ERROR: {host}:{port} never accepted a connection. "
f"Last error: {type(last_error).__name__}: {last_error}",
file=sys.stderr,
)
print(
"[entrypoint] If this is a Synology NAS: DSM's firewall filters traffic "
"between containers (FORWARD -j FORWARD_FIREWALL runs before Docker's own "
"rules and ends in DROP), and it reloads periodically, so an added iptables "
"rule does not survive. Fix:\n"
" sysctl -w net.bridge.bridge-nf-call-iptables=0\n"
"See docs/nas-install.md section 8.",
file=sys.stderr,
)
sys.exit(1)
PYEOF
}
run_migrations() {
wait_for_db
echo "[entrypoint] core schema: alembic upgrade head"
alembic upgrade head
for plugin in $ENABLED_PLUGINS; do
echo "[entrypoint] plugin schema: $plugin"
python -m app.plugins.cli install "$plugin"
done
echo "[entrypoint] migrations complete"
}
case "$cmd" in
api)
# --proxy-headers + --forwarded-allow-ips are what let the app see the
# real scheme through nginx/DSM. Without them every request looks like
# plain http and the session cookie loses its __Host- prefix and Secure
# flag (app/identity/cookies.py:22). The allow-list is the internal
# docker network only; this port is never published directly.
exec uvicorn app.main:create_app \
--factory \
--host "$API_HOST" \
--port "$API_PORT" \
--workers "$API_WORKERS" \
--proxy-headers \
--forwarded-allow-ips "${FORWARDED_ALLOW_IPS:-*}" \
--no-server-header \
"$@"
;;
worker-sync) WORKER_ROLE=sync exec python -m workers.run "$@" ;;
worker-jobs) WORKER_ROLE=jobs exec python -m workers.run "$@" ;;
worker-all) WORKER_ROLE=all exec python -m workers.run "$@" ;;
migrate) run_migrations ;;
reindex) exec python scripts/reindex_search.py "$@" ;;
plugins) exec python -m app.plugins.cli "$@" ;;
shell) exec /bin/sh "$@" ;;
*) exec "$cmd" "$@" ;;
esac