Nothing from the 2026-08-19 code review now lives only in its plan file. Four
new roadmap items, all `candidate`:
- csrf-tokens — §4.4. The review recorded a decision to add per-session tokens
but never turned it into a task, and security.md still argues the header
checks suffice. The item says to settle which of the two stands first.
- template-data-typing — §7.1, map[string]any template payloads.
- structured-logging — §7.2 and §5.5, log/slog plus the per-package logf
aliases.
- review-2026-08-followups — the small remainder: backup re-auth, the missing
DMARC-service / handler / error-page / corrupted-backup tests, and the nine
minor findings (§6.1, §6.2, §6.4, §7.4, §8.6, §9.3, §10.3-10.5) that were
never carried into the phase tables.
§7.3 (build tags for the /proc reader) is deliberately not carried over: it was
declined during the pass, and both documents now say so, so it does not get
re-raised at the next review.
The plan's status table links each open row to its roadmap item, and the
roadmap's recommended-order note points back at the plan. Also unlinked two
CHANGELOG references to plan files deleted after those releases shipped; the
text stays, the dead links do not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Verified the tree against docs/plans/code-review-2026-08.md and finished the
tasks that were only partly done, plus two defects the pass introduced.
- bcrypt: the setup account was still hashed at DefaultCost (10). The work
factor now has one definition, auth.BcryptCost = 12, used everywhere.
- rate limiter: protecting blocked buckets from eviction was unbounded — an
attacker with many source addresses could grow the map by making each one
fail its way to blocked. Blocked buckets are now evicted past
maxBuckets * 4, nearest expiry first, logged once per window. Tests cover
both halves.
- postqueue: the 5s timeout left the output size unbounded; reads are now
capped at 4 MiB and report truncation.
- spec references: the ~76 `spec N` citations CHANGELOG [0.5.0] claimed were
gone are removed from build/, deploy/, the first two migrations and the
release workflow, with the affected comment paragraphs re-wrapped. Comments
only; shell/YAML/SQL syntax re-checked.
- schema-migrations.md: schema section was headed v9 with the chain at v10,
and three links pointed at deleted plan files.
- internal/configsafe: the shared empty/forbidden-character check behind the
OpenDKIM and Postfix config writers; each writer keeps its own forbidden set.
- dropped the parseDomainRateLimitForm/parseAppRateLimitForm pass-throughs
left behind by the parser merge (only their own tests still called them).
- plan doc now carries a status table: what shipped, what was already in
place, what was declined (//go:build linux for the /proc reader) and the
open CSRF-token question.
Not done, each its own phase: typed template data structs, log/slog, DMARC
service and handler tests, error-page/corrupted-backup tests, backup re-auth.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
38 findings across architecture, code quality, security, GUI, tests, documentation, and AGPL compliance. Prioritised 6-phase implementation plan with model recommendations.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
No exploitable findings across inbound relay, DMARC ingestion, auto rate
limiting, auth-IP allow-lists, and the new panel surface. Two deliberate
behaviours the pass examined are now recorded as accepted risks:
unauthenticated DMARC report ingest and fail-open auth-IP enforcement.
This closes the note that the inbound-relay path awaited its own review.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Plans for domain-stats-auto-ratelimit, dmarc-reports, inbound-relay, panel-docs, queue-retries, and send-log-retention are deleted — all shipped between 1.3.1 and 1.7.0. Only unimplemented plans remain.
Co-authored-by: Cursor <cursoragent@cursor.com>
Review pass over the 1.4.0-1.9.1 work.
Port 25 no longer inherits main.cf's submission milter chain: smtpd_milters
is always overridden on smtp/inet, to the antispam milter or to nothing.
Without that, OpenDKIM (strict, tempfail) gated relayed mail during a signing
outage, and the journal-milter filed inbound mail into the outbound send log
under the sender's domain -- so Deliveries showed other people's mail and a
forged From: consumed that domain's level-2 budget. architecture.md already
described the intended behaviour; the code now matches.
DMARC reports are checked before they are stored: the policy domain must be a
configured sending domain, and must match the +tag of a per-domain hosted
address. The rua address form is predictable, so anyone able to reach it could
file reports under any domain and evict genuine ones through the retention cap.
Zip attachments are now decompressed as well as gzip.
The auto level-2 ceiling is scaled to the level-1 window before the multiplier
is applied; any window other than 3600s shifted the allowance by window/3600.
Migration 0010 indexes send_log(app_login, created_at) -- both the per-MAIL
FROM rate-limit count and the per-application statistics were scanning the
created_at range.
e2e now regenerates the Postfix config in-container for all four port-25 flag
combinations and requires postfix check to pass; the 1.9.1 crash-loop shipped
in 1.4.0 and survived three releases because only the flags-off path was
exercised.
Migration 0009 stays as shipped -- its conversion branch only fires for a
database carrying application-scope allowed_ips from before 1.9.0. The schema
reference notes that the old list was permissive and the new column is
restrictive; the guide documents the allow-list as a sending control on a
fail-open path, not an authentication boundary.
Also: gofmt across cmd/ and internal/, unused duplicate validators dropped from
internal/web/validate, export no longer swallows store errors.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Port 25, DMARC routes, restore Resync vs reload, image pin, and roadmap
index were stale relative to the running panel and Postfix config.
Co-authored-by: Cursor <cursoragent@cursor.com>
Add journal and allow/deny list design, quarantine candidate, roadmap entries, and Composer to Opus to Fable routing note.
Co-authored-by: Cursor <cursoragent@cursor.com>
Centralises migration history, 1.x rules, and the 2.x squash gate so
roadmap and architecture no longer carry a stale v5 snapshot.
Co-authored-by: Cursor <cursoragent@cursor.com>
Application client IP allow-list restricts which addresses may submit as a SASL login; level-2 rate limits override the domain ceiling per application (higher or lower, capped at L1). Migration 0009, authips form, milter enforcement, export/import, and operator docs.
Co-authored-by: Cursor <cursoragent@cursor.com>
Add in-panel Help: CSS-only drawer with seeded Status and domain topics,
«?» entry points on cards, and guide.md boundary for what stays in-repo.
Co-authored-by: Cursor <cursoragent@cursor.com>
Receive DMARC aggregate reports on port 25 and show parsed summaries in the panel. Close Unreleased; pin compose and docs to 1.7.0.
Co-authored-by: Cursor <cursoragent@cursor.com>
Add 30-day send statistics and auto level-2 rate limits on the domain page. Close Unreleased; pin compose and docs to 1.6.0.
Co-authored-by: Cursor <cursoragent@cursor.com>
Global administrators can set delivery journal retention (7-365 days) in SQLite;
SEND_LOG_RETENTION_DAYS seeds the initial default and the log-tailer re-reads
the value every prune cycle without a restart.
Co-authored-by: Cursor <cursoragent@cursor.com>
Port 25 accepts only configured domains and listed recipients, then forwards to an upstream; the outbound path is unchanged when the flag is off.
Co-authored-by: Cursor <cursoragent@cursor.com>
Close Unreleased for retry-policy panel, persistent queue, and self-contained backups; pin compose and docs to 1.3.1.
Co-authored-by: Cursor <cursoragent@cursor.com>
Numbers come from a one-shot postconf -h at panel start so a manual override is visible after restart, without inventing an attempt count.
Co-authored-by: Cursor <cursoragent@cursor.com>
Move the mail queue under /data so recreate no longer drops deferred mail, and archive data/, compose, .env, and certs/ together for restore on a fresh host.
Co-authored-by: Cursor <cursoragent@cursor.com>
Domains and Inbound keep the add field in the list card. The DMARC candidate hub drills into a domain roll-up and a parsed aggregate report, including a third-party fail. The running panel is unchanged.
Co-Authored-By: Cursor <cursoragent@cursor.com>
Split the hash-sheet prototype into one HTML file per screen, composed from stack, pair, measure, and fill. system.html is the grammar; shell.js is shared chrome. The running panel is unchanged.
Co-Authored-By: Cursor <cursoragent@cursor.com>
Restore Status readings, inbound MX DNS and recipient modes, two-column Backup and Settings, domain-card help, and Host/name Type field height.
Co-Authored-By: Cursor <cursoragent@cursor.com>
Click-through gallery for current and roadmap screens, with a hybrid width proposal and a phone layout for emergency ops. The running panel is unchanged.
Co-Authored-By: Cursor <cursoragent@cursor.com>
imagetools has no rm subcommand; the merge cleanup had been failing silently. Document Gitea-to-GitHub tag mirror pitfalls in development.md. CHANGELOG [1.3.0] updated.
Co-Authored-By: Cursor <cursoragent@cursor.com>
Run release.yml directly on release:published instead of a dispatcher
workflow. Remove per-arch tags from GHCR after the manifest merge so only
X.Y.Z remains visible to operators.
Co-authored-by: Cursor <cursoragent@cursor.com>
GitHub recorded release published at 16:44:21Z but no release.yml run
started. A thin release-on-publish workflow listens for publish and starts
release.yml via workflow_dispatch (always creates a run). release.yml now
checks out vX.Y.Z from the version input, not main HEAD.
Co-Authored-By: Cursor <cursoragent@cursor.com>
Bare git tag pushes no longer start the image build; publishing a GitHub
Release does. workflow_dispatch still requires an explicit X.Y.Z version.
Docs explain draft vs published releases and that deleting a tag on GitHub
converts a published release back to draft.
Co-Authored-By: Cursor <cursoragent@cursor.com>
Fix architecture route RBAC, restore Resync, and session/password wording;
correct guide restore/session/PTR claims and README port-587 text; point
the CSRF ADR at authz.go for global route gating.
Co-authored-by: Cursor <cursoragent@cursor.com>
The section explained what a restore means conceptually but gave no
runnable commands. Add a step-by-step in-place restore, a
move-to-a-new-host variant, and the encrypted-backup decrypt-first
sequence, plus the actual version-mismatch error text so operators
know what to expect.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Internal (non-operator) env vars were listed in guide.md's Environment
variables section even though the guide's own text says operators
aren't meant to touch them. They now live in architecture.md §
Configuration, the as-built/internals doc; guide.md keeps a one-line
pointer.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Installation now reads Ports, Local trial, Initial setup, Full
deployment (with Fixed image tag nested under it), Environment
variables, Reverse proxy. The step-by-step production deploy and the
per-proxy TLS commands move from README's "Reference deploy" into the
operator guide; README keeps a short pointer instead of duplicating
the walkthrough.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Splits mixed DNS-setup, Operations, Rate-limiting, and Backup sections
along the instance/domain boundary and fixes README anchors that
pointed at the old #dns-setup heading.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Every phase (P0-P7) is closed, so the plan file goes per its own exit criteria; history lives in git and the CHANGELOG. The roadmap's recommended order returns to queue-retries and then inbound-relay, and the order notes in both plans stop pointing at the deleted file, as does the 1.2.5 changelog entry (same treatment as logrotate-mode.md).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Fable review of the send-log authorization, tryAdmit, session create and app-delete ordering against security.md found no further issues: every send-log query path states its scope and an empty scope matches nothing, rejected rows cannot consume rate-limit budget, the milter's nil-registry fail-open is test-only, and each failure residue of the reordered delete fails safe. Outcome recorded in security.md's header and the plan; nothing added to the accepted risks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When CheckRestore accepts a backup manifest, the panel re-derives OpenDKIM tables and the Postfix sender map from SQLite on that first boot and reloads both daemons, so archive/database drift is healed before mail flows.
Co-authored-by: Cursor <cursoragent@cursor.com>
Close the remaining low-risk items from the full-tree review: rename the settings handler, query assigned domains in SQL, bound the login limiter map, collapse panel.js show/hide helpers, and soften DMARC copy that promised a future in-panel receiver.
Co-authored-by: Cursor <cursoragent@cursor.com>
IBM Plex WOFF2 files were shipped without the SIL OFL 1.1 text, NOTICE
told modifiers to edit layout.html for a Source URL that lives in
legal.go, and workflow_dispatch took GITHUB_REF_NAME as the version so a
run from main would publish ghcr.io/...:main.
Closes P5 of docs/plans/code-review.md. CHANGELOG updated.
Co-Authored-By: Cursor Grok 4.6 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Roadmap item and plan: show first retry delay, backoff cap and queue
lifetime on Mail queue and the delivery page, reading postconf -h once
at panel start so a manual override is visible. Explanation only; not
implemented yet. CHANGELOG updated.
Co-Authored-By: Cursor Grok 4.6 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Restore is not a code path in the panel — the operator extracts the archive
onto /data and starts the image — so it had no test. cmd/panel/restore_test.go
now performs that path in process: download a backup from a running panel
through POST /backup (plain and encrypted), unpack it the way tar -xzf does,
and boot a second panel on the result through run()'s own startup order
(CheckRestore, store.Open, newPanel, Start).
Covered: the restored panel shows the domain and journal the archive carried
and finds the DKIM key, sasldb2 and Postfix sender map where its configuration
says they are; the setup link is not reopened by a restore; a session that
predates the backup still works, as the guide documents; an encrypted download
restores identically; a data directory from another version is refused with
both versions named and the manifest kept for the retry.
serveHTTP is split so the composition it performs (newPanel) can be started
without binding a port. No behaviour change.
Closes the optional P4 item in docs/plans/code-review.md, and with it the
"HandleBackup POST untested" gap from the review's test section. CHANGELOG
updated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
- internal/web/auth/ratelimit_test.go: the login/setup limiter's ceiling,
per-address scope, window reset and the sweep that keeps finished
buckets out of memory.
- internal/web/auth/handlers_test.go: sign-in (session issued, refusals
that do not reveal which usernames exist, a lockout a correct password
cannot bypass, the setup hint before the first administrator) and the
one-time setup link (creates the first global administrator and then
closes, rejects a wrong or expired token, refuses credentials the panel
would not accept later, rate-limited).
- internal/web/handlers/authz_test.go: every global-only route answers a
domain administrator — and a request with no principal — with 404, with
a positive control so the table cannot pass on a handler that always
404s. This is the check that would have caught the send-log leak.
- test/e2e: CoreDNS pinned to 1.14.6 instead of latest; the level-1
failure message quoted RATE_LIMIT_MESSAGES_PER_IP=5 while the stand
sets 50.
docs/plans/code-review.md P4 checked off except the optional backup-boot
test; CHANGELOG updated. No production code changed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
- Deliveries fragment/pagination links urlquery-encode the domain/app
filters instead of splicing them into the query string raw.
- The four polled regions (deliveries rows, status, mail queue, system
log) carry aria-live="polite" so a screen reader announces refreshes.
- security.md accepted risks note that data-confirm prompts are
JavaScript-only and why that is acceptable.
docs/plans/code-review.md P3 fully checked off; CHANGELOG updated.
Co-authored-by: Cursor <cursoragent@cursor.com>
- .flash.error now renders on the danger surface instead of the success
one; RateLimitErr previously showed as green with red text.
- User delete goes through a confirmation page (GET/POST
/users/{uid}/delete), matching the domain-delete pattern, instead of a
plain submit button next to Save with no confirmation.
- Extracted the repeated DNS Host/Type/Value markup on a domain's page and
the duplicated Settings credentials form into shared partials. No
behaviour change.
docs/plans/code-review.md P3 checked off; CHANGELOG updated.
Co-authored-by: Cursor <cursoragent@cursor.com>