Commit Graph
17 Commits
Author SHA1 Message Date
lukasandClaude Opus 5 e1371c4f84 doctor: the antispam check printed nothing on the install it was for
It hid its own result behind an `elif ... ; fi` with no `else`, gated on
`docker compose ps --status running --services` — a flag older Compose
does not accept. On the QNAP the flag failed, the elif went false, and
the check produced no output whatsoever. A check that goes quiet when
something unexpected happens is the failure it was written to catch.

Drop the gate and classify the probe's own output instead: AUTHORISED,
a 401, a stopped stack, or anything else. Every branch prints, and the
"anything else" branch shows the raw tail rather than swallowing it.
Also say so when antispam is absent from ENABLED_PLUGINS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
2026-09-21 22:28:58 +02:00
lukasandClaude Opus 5 059fa0ade3 Antispam: the app could never authenticate to the learning engine
Rspamd answers on two ports and only one of them asks for a password:
the scan worker takes /checkv2 anonymously, the controller demands
`Password:` for /stat and /learnspam. `webmail.sh init` generates
RSPAMD_CONTROLLER_PASSWORD and docker-compose hands it to the rspamd
container — but the shared x-backend-env block that feeds api and
worker-* never carried it. The plugin reads it from the environment,
found "", and sent no header.

So classification worked and learning never did. Every learn returned
401 and the plugin swallowed it, as it is built to: an engine problem
must not lose mail. Bootstrap logged "engine unavailable", passive
learning from Inbox->Spam moves taught nothing, and the single visible
trace was `"bayes": null` on GET /api/plugins/antispam/stats, on an
install whose verdict counts were ticking up normally.

Verified against a live engine: with no password /stat and /learnspam
both 401 and rspamd.learn() returns False; with it, stats returns and
learn returns True.

  - pass RSPAMD_CONTROLLER_PASSWORD to the app containers, in both the
    production and dev compose files
  - doctor now makes the real call from inside the api container, with
    the password that container actually holds, and says plainly that
    nothing will be learned if it fails
  - a test reads both compose files, because this is a missing line that
    review already let through once

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
2026-09-21 22:17:29 +02:00
lukasandClaude Opus 5 2251c7ca6d Deploy: set_env_value could not add a key, and said it had
`sed "s|^KEY=.*|KEY=value|"` only rewrites a line that already exists. A setting
introduced by a later release is absent from an older .env, so the substitution
matched nothing, reported success, and the value vanished — while
`ensure_secrets` printed "RSPAMD_CONTROLLER_PASSWORD was empty — generated one".

The v1.1.0 upgrade then ran with a blank controller password: every compose call
warned, the engine came up unauthenticated, and learning would have failed
against it. Classification was unaffected, which is exactly what made it quiet.

Now appends when the key is absent — ensuring the file ends in a newline first,
so the key cannot be glued onto the last line of a hand-edited .env — and
verifies the value is present afterwards rather than trusting sed's exit status.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
2026-09-17 20:52:21 +02:00
lukasandClaude Opus 5 b54ccbe653 Deploy: a release that adds a service must actually start it
Three things stood between v1.1.0 and a working anti-spam engine on an existing
install, each silent:

`update` rolled only worker-sync, worker-jobs, api and web. A release that adds
a service — this one adds two — would report success while never creating it.
It now converges the rest with `up -d --no-recreate`, which creates what is
absent and leaves the rolled services alone.

.env.prod.example pinned ENABLED_PLUGINS to the four plugins that existed when it
was written. The entrypoint's default includes antispam, but an explicit value in
.env wins, so migrate would skip the plugin's tables and every antispam request
would fail at runtime rather than at install. The template is fixed for new
installs; `doctor` now warns existing ones, comparing .env against the
entrypoint's default so there is still one list, not two.

And the runbook now documents what the engine costs (~150-300 MB), that nothing
depends on it by design, that `init` must be re-run for the controller password,
and that its Bayes database is derived data like the search index.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
2026-09-17 20:44:25 +02:00
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
lukasandClaude Opus 5 348daf8ad7 Deploy: up died on QuTS hero's older bash
`compose "${profile[@]}" up -d "${build[@]}"` aborts with "profile[@]:
unbound variable" on bash 4.3 and older, which treats expanding an EMPTY
array under `set -u` as unbound. DSM ships a newer bash, so this only
surfaced on QNAP. The ${arr[@]+"${arr[@]}"} form expands to nothing when
empty and is identical otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
2026-09-16 21:06:17 +02:00
lukasandClaude Opus 5 64ef3893d3 Deploy: add pull, the verb a first install from a registry needed
`up` only builds images that are missing locally, and `update` refuses to
run without an existing database to back up — so a fresh install that is
meant to fetch prebuilt images had no command that did it. `pull` fills the
gap and fails loudly if REGISTRY_PREFIX is still the local default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
2026-09-16 20:35:29 +02:00
lukasandClaude Opus 5 8fce86559e Admin panel on its own port, and a registration gate
Three strands that only make sense together, so they land together.

Registration gate (migration 0016). POST /auth/register was open to anyone
who could reach the port. Now the first account on an empty instance is
always allowed, becomes the administrator, and closes registration behind
itself; after that it takes an admin reopening it. The decision is made
under a row lock on app_settings so two simultaneous first signups cannot
both see an empty table and both claim administrator. Existing installs
keep their oldest account as admin and start closed.

Instance default quota (migration 0017). users.quota_bytes is snapshotted
at creation from app_settings.default_quota_bytes, so changing the default
affects later accounts only and never resizes a mailbox that exists.

The panel is a separate app, not a page in the mail client. admin.html is
its own Vite entry (5.8 KB next to the client's 1.7 MB; they share only the
vendor chunk), with its own administrator sign-in. One nginx now serves two
listeners: :80 is the mail client and 404s both /admin.html and
/api/admin/*, :81 is the panel. docker-compose publishes them as WEB_PORT
and ADMIN_PORT. The boundary is nginx refusing paths on one listener, not
process isolation — CurrentAdmin still guards every admin route — so
ADMIN_PORT must be restricted to the LAN, which the runbook now covers for
both DSM and QNAP.

Also QNAP: deploy/webmail.sh finds Container Station's docker under
/share/*/.qpkg/container-station/bin, which is off an SSH session's PATH,
before anything shells out to it. docs/nas-install.md gains a QuTS hero
runbook (§2.6), splits the DSM firewall section into container-to-container
(§8.1) and the egress case that makes account-add time out while
autodiscovery appears to work (§8.2), and scopes both to Synology.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
2026-09-16 20:34:08 +02:00
lukasandClaude Opus 5 ff64a5dfe7 Deploy: create the data directories instead of assuming Docker will
"Bind mount failed: '/volume1/docker/webmail/data/postgres' doesn't exists" on
Synology after the data directory was removed. Docker on ordinary Linux creates
a missing bind-mount source silently; DSM's daemon refuses and fails the
container. Every service mounted its own subdirectory, so every one of them
needed to already exist — including the `init` service whose whole job was to
create them, which could therefore never run.

- init now mounts DATA_ROOT itself, once, and creates postgres/blobs/backups/
  redis/meili/state inside it. Only DATA_ROOT has to pre-exist.
- postgres gained the init dependency it was missing, so its data directory is
  never demanded before init has made it.
- init only runs `chown -R` when ownership is actually wrong, rather than
  walking the whole blob store on every start.
- webmail.sh up creates DATA_ROOT and, if it cannot, says so with the mkdir to
  run rather than leaving the daemon to produce the error above.

Verified from an empty DATA_ROOT: init creates all six directories, postgres
starts after it, migrations apply, stack serves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nux3LCu4zP9nih4BRhPyHw
2026-09-14 20:41:09 +02:00
lukasandClaude Opus 5 d74e1f97b2 Deploy: make a missing .env name its own fix
With no .env found, compose fell back to a sentinel path and the daemon
reported: Bind mount failed: 'DATA_ROOT-is-unset/postgres' does not exist —
accurate, but it tells the operator nothing about what to do, and this is the
most common way the install goes wrong (the file is generated by `init`, not
shipped in the repo, so any re-copy of the folder loses it).

The sentinel is now /NO-.env-FOUND-RUN-deploy_webmail.sh-init-FIRST, so the
daemon's own error states the fix. doctor also checks for a missing or empty
.env before anything else and says the same thing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nux3LCu4zP9nih4BRhPyHw
2026-09-14 20:15:19 +02:00
lukasandClaude Opus 5 2f03bee45b Deploy: drop cpus: limits and stop assuming docker compose exists
Two NAS realities, both hit on a real Synology box.

- "NanoCPUs" error part-way through the project build: `cpus:` maps to the
  NanoCPUs field, which DSM's Docker daemon rejects. Removed from all three
  services (and from .env.prod.example). mem_limit uses the much older Memory
  field and works, so memory caps remain the throttle. Noted in the header
  next to the other DSM constraints.
- `docker compose build` on DSM: "'compose' is not a docker command". DSM and
  QTS ship the standalone `docker-compose` binary with no plugin subcommand,
  so every one of the script's 21 compose invocations was wrong for the target
  platform. The script now detects which is present, and only passes `--wait`
  when it finds v2 — on v1 it falls back to the health_gate it already had,
  which is also now run after `up` on both paths.

That the build reached container creation before failing says their compose
parses the rest of the file (anchors, profiles, service_completed_successfully,
top-level name), so this was a daemon-level rejection, not a schema one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nux3LCu4zP9nih4BRhPyHw
2026-09-14 20:09:32 +02:00
lukasandClaude Opus 5 f3dcd938b1 Deploy: fix the two things that broke the Container Manager build
DSM reported "invalid interpolation format for x-backend-env.MASTER_KEY" and
"required variable MASTER_KEY is missing a value". Both were mine.

1. DSM's bundled Compose cannot parse `${VAR:?message}`. Those guards are gone;
   interpolation now sticks to `${VAR}` and `${VAR:-default}`, the forms every
   Compose understands. Bind mounts keep a `${DATA_ROOT:-/DATA_ROOT-is-unset}`
   sentinel so a missing config still cannot scatter directories across the
   root of the NAS filesystem, and the header says to keep it this way.
2. Config was at docker/.env.prod with a `.env` symlink beside it. Container
   Manager did not pick that up. The real file is now `.env` in the project
   folder — what every compose front end reads without being told — and
   docker/.env.prod becomes a symlink to it. `init` and `require_env` migrate
   an existing install in place, keeping its MASTER_KEY.

Also, while testing the migration: `init` only generated secrets when creating
a file from scratch, so a migrated or hand-written .env with an empty
POSTGRES_PASSWORD started Postgres in trust-auth mode. It now tops up empty
secrets — and never touches a MASTER_KEY that already has a value, since
replacing it would make every stored credential undecryptable. doctor flags an
empty password too.

Verified: parses cleanly with no .env at all (the failure DSM hit), migration
preserves an existing key, and `docker compose up -d --wait` from the project
folder brings the stack up healthy and serving.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nux3LCu4zP9nih4BRhPyHw
2026-09-14 19:49:03 +02:00
lukasandClaude Opus 5 5c444a6fd3 Deploy: complete the compose flattening (paths + script pointer)
Fixes the previous commit, which went out incomplete: a stale pathspec made
its `git add` abort, so the file move landed without the edits that make the
moved file work.

- docker-compose.yml: build contexts `..` -> `.` and the backup sidecar mounts
  ./docker/backup.sh, both now correct for a file at the repo root. Header
  explains why it lives here and to keep newer compose features out of it.
- deploy/webmail.sh: COMPOSE_FILE points at the root file, not the deleted
  docker/docker-compose.prod.yml.

8ddac22 on its own could not start: every build context and the backup mount
pointed one directory too high, and the control script referenced a file that
no longer exists.

Re-verified after this commit with `docker compose up -d --wait` from the repo
root: all services healthy, app serving, backup sidecar resolving its script.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nux3LCu4zP9nih4BRhPyHw
2026-09-14 19:37:58 +02:00
lukasandClaude Opus 5 7f6b76ec12 Deploy: explain the root-owned env file instead of leaking "Permission denied"
init runs as root on a NAS, so docker/.env.prod is mode 600 owned by root and
the data directories are root-owned. Running any subcommand as a normal user
produced a bare redirect error naming an internal line number, with no hint
about the cause.

require_env now checks readability first and says who you are running as, the
exact sudo command to repeat, and that `sudo -i` avoids it for the session.
Docs note it beside the show-key step and above the command reference.

Reproduced with a root-owned 600 env file read as a normal user; the message
is what that case now prints.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nux3LCu4zP9nih4BRhPyHw
2026-09-14 19:29:22 +02:00
lukasandClaude Opus 5 24031f1daa Deploy: make a no-SSH install possible from the DSM GUI
The install guide was SSH-first and left the GUI as a footnote, but the two
things it could not do were both fixable rather than inherent.

- `init` takes --data-root / --domain / --tz / --port / --bind, so configuring
  is one command instead of hand-editing a dotfile that File Station hides by
  default. Re-running with flags updates an existing .env.prod.
- `show-key` prints MASTER_KEY, so it can be backed up without opening that
  hidden file — intended to be redirected to a file, copied into a password
  manager, and deleted.
- usage() printed a hardcoded line range that spilled into the code once the
  header grew; it now prints the leading comment block however long it gets.
- docs §2.5 is a complete GUI walkthrough: Gitea "Download ZIP" + File Station
  (with the chmod a zip download needs), two one-off Task Scheduler jobs for
  config and key backup, then the Container Manager project on the repo root
  named webmail-prod. Plus a table for running update/rollback/backup/restore
  /doctor/reindex as Task Scheduler jobs, and why the UI's own Build action is
  not a substitute for `update` (no pre-backup, no health gate, no rollback).

Verified by running the documented sequence verbatim: flag-driven init,
show-key, then `docker compose up -d --wait` from the project folder as the
GUI does — all services healthy, account created, backup and doctor tasks
working against the result.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nux3LCu4zP9nih4BRhPyHw
2026-09-14 19:18:41 +02:00
lukasandClaude Opus 5 cc26ae7eaf Deploy: make the NAS GUI path work, and stop sourcing the env file
Synology Container Manager and QNAP Container Station build a project from a
folder and look for `docker-compose.yml` in it. Pointing either at `docker/`
finds the DEV service stack and starts the wrong thing, so production now has
a root-level entry point.

- docker-compose.yml at the repo root: `include`s the prod stack (relative
  paths resolve against the included file's directory, so the build context
  and backup.sh still resolve) and pins `name: webmail-prod`. A project name
  typed into the GUI overrides the file's, so the docs say to type the same
  one.
- webmail.sh init now drops a `.env` symlink to docker/.env.prod in the repo
  root: the GUI reads `.env` from the project folder, and a symlink keeps one
  file to edit instead of two copies that drift.
- Required-variable guards on DATA_ROOT / MASTER_KEY / POSTGRES_PASSWORD /
  APP_DOMAIN. A bare `docker compose up` with no env file used to interpolate
  DATA_ROOT to "" and bind-mount /postgres at the root of the filesystem; it
  now aborts with a message naming the variable.
- require_env no longer sources the env file. Sourcing executed it as shell:
  ENABLED_PLUGINS=calendar contacts labels filters ran `contacts` as a command
  and broke every subcommand, and anything else in the file that holds the
  master key would have been executed too. Replaced with a compose-compatible
  KEY=VALUE parser; the template quotes the value as well.
- doctor: no longer aborts on a not-yet-created DATA_ROOT (set -e + pipefail
  killed it inside a `df` assignment — on exactly the pre-install run where
  its NFS/SMB check is the one that matters), and probes the nearest existing
  ancestor so that check works before the directory exists.
- docs §2.5 rewritten with what the GUI can and cannot do: no compose
  profiles (so no Meilisearch), no health-gated update, no restore; plus the
  Task Scheduler route for generating MASTER_KEY without enabling SSH, and
  why init/migrate showing "Exited (0)" is correct.

Verified by running `docker compose up -d --wait` from the repo root with no
-f and no --env-file (what the GUI does): all services healthy, migrations
applied, app served, and deploy/webmail.sh manages the same project.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nux3LCu4zP9nih4BRhPyHw
2026-09-14 19:03:42 +02:00
lukasandClaude Opus 5 5553b5febe Deploy: production Docker stack for a single host (Synology/QNAP NAS)
Everything needed to run the app on an Intel NAS that is also doing other
jobs: one published port, no host networking, memory/CPU caps and log
rotation on every container, and all state in bind mounts under DATA_ROOT
so mail survives image updates, project deletion in the NAS UI, and full
reinstalls — and so Hyper Backup/HBS3 can actually see it.

- docker/Dockerfile.backend: multi-stage, non-root (uid 10001), tini for
  the <10s graceful lease release. Installs the four plugins as entry-point
  distributions. One image, several roles via docker/entrypoint.sh
  (api | worker-sync | worker-jobs | migrate | reindex | plugins).
- docker/Dockerfile.frontend + nginx.conf: SPA + /api reverse proxy with
  the WebSocket upgrade for /api/ws, and X-Forwarded-Proto preserved so
  the API still emits __Host-/Secure cookies when TLS is terminated by
  the NAS reverse proxy (identity/cookies.py keys off request scheme).
- docker/docker-compose.prod.yml: project name webmail-prod, NOT webmail —
  docker-compose.yml (dev services) owns that name, and two compose files
  sharing one project name fight over each other's containers.
  Meilisearch is an opt-in profile; SEARCH_BACKEND=pg is the default so a
  small NAS pays nothing for search.
- migrate runs as a one-shot (core Alembic head + every plugin branch)
  that everything else waits on, so a bad migration can never leave new
  code running against an old schema.
- docker/backup.sh: nightly pg_dump verified with `pg_restore --list`
  before it counts as a backup, with retention and a baseline on first
  start.
- deploy/webmail.sh: init/doctor/up/down/status/logs/update/rollback/
  backup/restore/reindex/psql/shell. `update` is backup -> images ->
  migrate -> roll workers, api, web -> health-gate, with automatic
  rollback to the previous version if the gate fails. `doctor` refuses to
  be quiet when DATA_ROOT is on NFS/SMB (silent Postgres corruption).
- deploy/build-push.sh: build images off-NAS (the frontend build wants
  ~2GB RAM) and push to Gitea, or --save a tar to carry over.
- scripts/reindex_search.py: rebuild the search index from Postgres alone.
  Every SearchDoc field is already stored, so the index is derived data
  that never needs backing up — this closes the only real objection to
  running Meilisearch on a box whose volumes may be wiped.
- pyproject: package-data for accounts/presets.yaml and the shared plugin
  Alembic env. Without it a non-editable install (i.e. any image) silently
  ships a package that fails at runtime.
- GET /api/version, used by the deploy health gate.

Verified end to end on a real stack: fresh install, all services healthy,
core + 4 plugin migration branches applied, login/SPA/plugin bundles
through nginx, WS upgrade returning 101, __Host- cookies under a
simulated TLS proxy, backup -> restore round-trip preserving user and
plugin schema, failed-update auto-rollback (both a bad migration and a
boot-crashing API), and Meilisearch profile serving a live query.
ruff + mypy --strict clean, 363/363 backend tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nux3LCu4zP9nih4BRhPyHw
2026-09-14 18:53:50 +02:00