docs: retire the code-review plan, its content now lives where it belongs
test / test (push) Has been cancelled

The review document was the last place several findings existed, so it could
not just be deleted. Everything still useful moved out first:

- roadmap items csrf-tokens, template-data-typing, structured-logging and
  review-2026-08-followups are now self-contained — each row names the file,
  the symptom and what "done" means, instead of pointing at a paragraph number
  in a plan file.
- security.md's CSRF accepted-risk bullet now names the csrf-tokens item, so
  the two records of that decision cannot drift apart unnoticed; the item says
  the same in the other direction.
- CHANGELOG [Unreleased] records the review itself in its intro rather than as
  an added file: the document was created and removed inside one unreleased
  cycle, so it never was part of a release.
- the declined finding (//go:build linux for the /proc reader) keeps its
  rationale in the roadmap so it is not raised again.

Also repaired three links to inbound-relay.md, deleted back when that feature's
plan was cleaned up: the two inbound plans now point at architecture.md § Mail
path for the as-built behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-08-20 00:48:13 +03:00
co-authored by Claude Opus 5
parent 1e4746e52f
commit b29f4845ba
6 changed files with 92 additions and 459 deletions
-385
View File
@@ -1,385 +0,0 @@
# Рецензирование кодовой базы SelfPost — август 2026
Дата: 2026-08-19.
---
## Общая оценка
SelfPost — зрелый, хорошо структурированный проект. Архитектура соответствует масштабу (self-hosted SMTP-реле с веб-панелью), код читабелен и поддерживаем, тестовое покрытие обширное (~60 тест-файлов), документация исключительно качественная, лицензионное соответствие AGPL-3.0 полное. Ниже — конкретные находки и план устранения.
---
## 1. Архитектура и структура проекта
**Оценка: отлично.** Стандартная Go-компоновка (`cmd/`, `internal/`, `test/e2e/`), чёткое разделение слоёв (store → service → handler → template). Два бинарника (`panel`, `selfpost-backup`), единый Docker-образ с supervisord. Всё соответствует масштабу проекта — нет ни over-engineering, ни архитектурных долгов.
---
## 2. Логические ошибки и баги
### 2.1 [Medium] SPF-checker не обрабатывает CIDR-нотацию
- **Файл:** `internal/dnscheck/spf.go`, `parseIPs()`
- **Суть:** `ip4:192.168.1.0/24` парсится как единичный IP `192.168.1.0`. Панель ложно предупреждает, что SPF не покрывает сервер.
- **Исправление:** использовать `net.ParseCIDR` + `IPNet.Contains()`.
### 2.2 [Low] Дублирующаяся проверка в user update (мёртвый код)
- **Файл:** `internal/web/handlers/handlers_users.go`, строки 225–239
- **Суть:** два последовательных блока проверяют одно условие (понижение последнего глобального админа). Второй блок недостижим.
- **Исправление:** оставить один блок с контекстным сообщением.
### 2.3 [Low] Вводящее в заблуждение flash-сообщение в настройках
- **Файл:** `internal/web/handlers/handlers_settings.go`, строки 73–94, 246–273
- **Суть:** при изменении username+email flash говорит «сессии сброшены», хотя сессии сбрасываются только при смене пароля. Мёртвые ветки `"username-email"`, `"password-email"` в `settingsFlash()`.
- **Исправление:** строить flash-сообщение из отдельных boolean-флагов.
### 2.4 [Low] Утечка резервации inflight при обрыве SMTP
- **Файл:** `internal/milter/inflight.go`
- **Суть:** если SMTP-соединение обрывается без `Abort()`, резервация живёт до TTL (60 сек). При высокой нагрузке возможен ложный отказ.
- **Исправление:** задокументировать компромисс в `docs/security.md`.
---
## 3. Отсутствующие CSS-определения на DMARC-страницах (баги GUI)
### 3.1 [Bug] 6 CSS-классов используются в шаблонах, но не определены в `panel.css`
| Класс | Шаблоны | Влияние |
|---|---|---|
| `.pair` | `dmarc.html`, `dmarc_domain.html`, `dmarc_report.html` | Контейнер-сетка не работает — карточки стакаются вертикально |
| `.attn` | `dmarc_report.html` (`.card.attn`) | Нет визуального выделения при fail > 0 |
| `.desk-only` | DMARC-страницы | Таблицы не скрываются на мобильных |
| `.phone-only` / `.phone-list` | `dmarc.html` | Мобильный список виден одновременно с таблицей на всех экранах |
| `.check-row` | `domain_detail.html` | Должно быть `.check` (определённый класс) |
**Исправление:** добавить отсутствующие правила в `panel.css`, включая media-запросы для `.desk-only`/`.phone-only`. Заменить `.pair` на `.split` или определить отдельно. Исправить `.check-row` → `.check`.
---
## 4. Безопасность
### 4.1 [Medium] Fail-open milter auth IP при ошибке БД
- **Файл:** `internal/milter/authip.go`
- **Суть:** при недоступности БД `authIPAllowed()` возвращает `true` — любой IP может отправлять.
- **Исправление:** оставить fail-open + добавить warning log.
- **Решение:** (b) fail-open с предупреждением.
### 4.2 [Medium] Нет ограничения stdin в DMARC-ingest
- **Файл:** `cmd/panel/dmarc_ingest.go`
- **Суть:** нет `io.LimitReader` — злонамеренное сообщение может исчерпать память.
- **Исправление:** `io.LimitReader(os.Stdin, 10<<20)`.
### 4.3 [Medium] Вытеснение bucket'ов в auth rate limiter
- **Файл:** `internal/web/auth/ratelimit.go`, `makeRoom()`
- **Суть:** при >10000 IP атакующий может вытеснить легитимные rate-limit записи, сбросив счётчики.
- **Исправление:** вытеснять bucket с ближайшим expiry, но заблокированные bucket'ы (failures >= max) не вытесняются до истечения TTL.
### 4.4 [Medium] Нет CSRF-токенов — только `Sec-Fetch-Site` / `Origin`
- **Файл:** `internal/web/security.go`
- **Суть:** защита зависит от браузера. В `docs/security.md` решение задокументировано с обоснованием, но defense-in-depth рекомендует токен.
- **Решение:** добавить per-session CSRF-токен.
### 4.5 [Low] CSP `unsafe-inline` для стилей
- **Файл:** `internal/web/security.go`
- **Исправление:** вынести inline-стили в stylesheet, удалить `'unsafe-inline'`.
### 4.6 [Low] `bcrypt.DefaultCost` (10) — рекомендуется 12+
- **Исправление:** определить `const bcryptCost = 12` и использовать везде.
### 4.7 [Low] Нет re-auth перед скачиванием backup
- **Файл:** `internal/web/handlers/handlers_backup.go`
- **Исправление:** запросить текущий пароль в форме backup.
### 4.8 [Very Low] Cookie без `__Host-` prefix
- **Файл:** `internal/web/auth/handlers.go`
- **Примечание:** в коде уже есть `Host-`-prefixed cookie при Secure, см. `auth_test.go`. Проверить, что покрытие полное.
### 4.9 [Low] zip-бомба в DMARC parse
- **Файл:** `internal/dmarc/parse.go`, `unzip()`
- **Исправление:** обернуть reader в `io.LimitReader`.
---
## 5. Дублирование кода
### 5.1 [Low] `writeFileAtomic` — две одинаковые реализации
- **Файлы:** `internal/domain/dkim.go`, `internal/postfix/write.go`
- **Исправление:** извлечь в `internal/atomicfile`.
### 5.2 [Low] `reloadViaSupervisor` — две реализации
- **Файлы:** `internal/postfix/postfix.go`, `internal/domain/opendkim.go`
- **Исправление:** извлечь в `internal/supervisor`.
### 5.3 [Low] `parseDomainRateLimitForm` / `parseAppRateLimitForm` — идентичная логика
- **Файл:** `internal/web/handlers/handlers_ratelimit.go`
- **Исправление:** объединить в `parseRateLimitForm`.
### 5.4 [Low] `applyDomainRateLimit` / `applyAppRateLimit`
- **Исправление:** параметризовать scope и saver.
### 5.5 [Very Low] `logf = log.Printf` — повторяется в 5+ пакетах
- **Исправление:** при переходе на `log/slog` (см. §7) проблема уйдёт.
### 5.6 [Very Low] `assertMapSafe` / `assertConfigSafe` — похожие проверки в нескольких пакетах
- **Исправление:** рассмотреть `internal/configsafe`.
---
## 6. Граничные случаи
### 6.1 [Low] `secretfile` принимает пустой пароль на уровне библиотеки
- **Исправление:** добавить `len(password) == 0` guard или задокументировать, что валидация на вызывающей стороне.
### 6.2 [Very Low] Fingerprint-коллизия при ротации лога
- **Файл:** `internal/logtail/offset.go`
- **Исправление:** включить inode в fingerprint.
### 6.3 [Low] Неограниченный вывод `postqueue -p`
- **Файл:** `internal/postfix/queue.go`
- **Исправление:** `io.LimitReader` или context timeout.
### 6.4 [Very Low] `parsePage` не ограничивает максимум
- **Файл:** `internal/web/handlers/handlers_monitor.go`
- **Исправление:** clamp к `lastPage`.
---
## 7. Рефакторинг и оптимизация
### 7.1 [Medium] Типизированные struct'ы для template data
- **Затронуто:** все `handlers_*.go`
- **Суть:** `map[string]any` с 40+ ключами (напр. `renderDomainDetail`) — хрупко, опечатки молчаливо дают zero-value.
- **Исправление:** определить struct'ы (`DomainDetailData`, `SettingsData` и т.д.).
### 7.2 [Medium] Структурированное логирование (`log/slog`)
- **Затронуто:** все пакеты
- **Суть:** нет уровней, структурированных полей, фильтрации.
- **Исправление:** инжектировать `*slog.Logger` через конструкторы.
### 7.3 [Low] Build tags для `/proc`-кода
- **Файл:** `internal/health/machine.go`
- **Исправление:** `//go:build linux` + stub для других платформ.
### 7.4 [Very Low] DNS resolver — 4 параллельных запроса вместо fallback
- **Файл:** `internal/dnscheck/resolver.go`
- **Исправление:** последовательный запрос с fallback, или задокументировать обоснование.
---
## 8. Документация
### 8.1 [Medium] CHANGELOG записывает удаление plan-файлов, которые ещё не удалены
- **Суть:** `[Unreleased]` Removed перечисляет 6 plan-файлов, но файлы ещё на диске.
- **Исправление:** проверить — файлы уже удалены; если да, пункт закрыт.
### 8.2 [Minor] `schema-migrations.md` — заголовок говорит v9, цепочка на v10
- **Исправление:** обновить заголовок и добавить индекс из миграции 0010.
### 8.3 [Low] ~40 ссылок на `spec N` в build/deploy-файлах
- **Суть:** CHANGELOG `[0.5.0]` говорит «код больше не ссылается на спецификацию», но shell-скрипты и Dockerfile не были очищены.
- **Исправление:** удалить ссылки на spec; где уместно — заменить на ссылку на актуальный документ.
### 8.4 [Low] `dmarc-ingest` symlink не документирован в `architecture.md`
- **Исправление:** добавить абзац в секцию Mail path.
### 8.5 [Low] Roadmap ссылается на plan-файлы, которые будут удалены
- **Исправление:** убрать ссылки на удалённые планы.
### 8.6 [Very Low] `dev/workflow.md` — устаревшая базовая версия
- **Исправление:** обновить «база 0.6.0» → актуальную.
---
## 9. Тесты
### 9.1 Общая оценка: сильное покрытие
Набор из ~60 тест-файлов покрывает: криптографию (`secretfile`), конкурентность (`milter` — 32 горутины), SQL edge cases (`store`), безопасность (CSRF-матрица, cookie-shadowing, injection-rejection, fail-open семантика), AGPL-compliance guards, e2e-сценарий. Тесты хорошо документированы комментариями.
### 9.2 Пакеты без тестов
| Пакет | Риск |
|---|---|
| `cmd/panel` (main.go, httpserver.go, journal.go, dmarc_ingest.go, ratelimit_recalc.go) | Low — startup-код, частично покрыт restore_test |
| `internal/dmarc` (service.go, addresses.go) | Medium — сервисный слой не протестирован |
| `internal/web/handlers` (handlers_apps.go, handlers_domains.go, handlers_users.go, handlers_dmarc.go, handlers_dmarc_reports.go, handlers_status.go, handlers_help.go) | Medium — нет прямых тестов (частично покрыты authz_test) |
| `internal/store` (store.go, sessions.go) | Low — sessions покрыты через auth_test |
| `internal/buildinfo` | Very Low — константы |
### 9.3 Слабые области тестирования
- `internal/postfix/dmarc_test.go` — только 2 теста, нет проверки injection
- `internal/web/validate/validate_email_test.go` — 4 кейса, тонкое покрытие
- `internal/store/stats_test.go` — нет edge case с нулевым трафиком
### 9.4 Отсутствуют тесты для
- Error page templates
- Corrupted backup archive handling
- Malformed XML в DMARC parse (oversized reports)
- Pagination edge cases в delivery log
- Concurrent store operations
---
## 10. GUI — дополнительные находки
### 10.1 [Low] Accessibility: help drawer
- Кнопка закрытия `<label class="help-close">` — нет `role="button"`.
- Scrim `<label class="help-scrim">` — нет `aria-label`.
### 10.2 [Low] Hardcoded URL guide
- `help.html` и `help_drawer.html` содержат захардкоженный GitHub URL вместо `{{.SourceURL}}`.
- **Решение:** формировать полный путь `{{.SourceURL}}/blob/main/docs/...`.
### 10.3 [Low] `.pair` vs `.split` — непоследовательность имён
- DMARC-шаблоны используют `class="pair"` для того же layout, что другие страницы называют `class="split"`.
### 10.4 [Very Low] Два `back_link` в `dmarc_domain.html`
- Необычный UX-паттерн (две ссылки «назад» подряд).
### 10.5 [Very Low] `initShowWhen` не вызывается после htmx swap
- Сейчас безопасно (формы не загружаются через htmx), но хрупко.
---
## 11. Соответствие лицензии AGPL-3.0
**Оценка: полное соответствие.** LICENSE, NOTICE, source offer в footer каждой страницы (включая login/setup), `/license` endpoint, OFL.txt для шрифтов, совместимость зависимостей проверена (BSD/0BSD/OFL). SPDX-заголовки per-file отсутствуют сознательно (задокументировано).
---
## План реализации
Приоритет: Critical → High → Medium → Low. Рекомендуемая модель для каждого пункта.
### Фаза 1 — Баги и безопасность (Sonnet)
| # | Задача | Модель | Оценка |
|---|--------|--------|--------|
| 1 | Добавить отсутствующие CSS-классы для DMARC-страниц (§3) | Sonnet | 30 мин |
| 2 | Исправить SPF CIDR-парсинг (§2.1) | Sonnet | 30 мин |
| 3 | Добавить `io.LimitReader` в DMARC ingest и parse (§4.2, §4.9) | Sonnet | 15 мин |
| 4 | Исправить rate limiter eviction (§4.3) | Sonnet | 30 мин |
| 5 | Добавить logging в fail-open auth IP (§4.1) | Sonnet | 10 мин |
| 6 | Убрать мёртвый код в user demotion guard (§2.2) | Sonnet | 5 мин |
| 7 | Исправить flash-сообщения в настройках (§2.3) | Sonnet | 15 мин |
| 8 | `.check-row` → `.check` в domain_detail (§3.1) | Sonnet | 2 мин |
### Фаза 2 — Дублирование кода (Sonnet)
| # | Задача | Модель | Оценка |
|---|--------|--------|--------|
| 9 | Извлечь `internal/atomicfile` (§5.1) | Sonnet | 20 мин |
| 10 | Извлечь `internal/supervisor` (§5.2) | Sonnet | 20 мин |
| 11 | Объединить rate-limit form parsing (§5.3–5.4) | Sonnet | 30 мин |
### Фаза 3 — Документация (Sonnet)
| # | Задача | Модель | Оценка |
|---|--------|--------|--------|
| 12 | Удалить plan-файлы или исправить CHANGELOG (§8.1) | Sonnet | 5 мин |
| 13 | Обновить `schema-migrations.md` (§8.2) | Sonnet | 5 мин |
| 14 | Удалить ссылки на spec из build/deploy (§8.3) | Sonnet | 30 мин |
| 15 | Документировать dmarc-ingest symlink (§8.4) | Sonnet | 5 мин |
| 16 | Исправить ссылки в roadmap (§8.5) | Sonnet | 5 мин |
### Фаза 4 — Рефакторинг (Sonnet / Opus для `log/slog`)
| # | Задача | Модель | Оценка |
|---|--------|--------|--------|
| 17 | Типизированные template data structs (§7.1) | Sonnet | 2–3 часа |
| 18 | Миграция на `log/slog` (§7.2) | Opus | 3–4 часа |
| 19 | Build tags для machine.go (§7.3) | Sonnet | 15 мин |
| 20 | Hardcoded guide URL → `{{.SourceURL}}` (§10.2) | Sonnet | 10 мин |
### Фаза 5 — Расширение тестов (Sonnet)
| # | Задача | Модель | Оценка |
|---|--------|--------|--------|
| 21 | Тесты DMARC service layer | Sonnet | 1–2 часа |
| 22 | Тесты handler'ов (apps, domains, users, dmarc) | Sonnet | 3–4 часа |
| 23 | Дополнить postfix/dmarc_test injection cases | Sonnet | 30 мин |
| 24 | Дополнить validate_email_test | Sonnet | 20 мин |
| 25 | Тесты error pages и corrupted backup | Sonnet | 1 час |
### Фаза 6 — Опциональные улучшения (Low priority)
| # | Задача | Модель | Оценка |
|---|--------|--------|--------|
| 26 | Удалить `unsafe-inline` из CSP (§4.5) | Sonnet | 1 час |
| 27 | bcrypt cost 12 (§4.6) | Sonnet | 10 мин |
| 28 | Re-auth перед backup (§4.7) | Sonnet | 30 мин |
| 29 | Accessibility help drawer (§10.1) | Sonnet | 15 мин |
| 30 | `io.LimitReader` для postqueue (§6.3) | Sonnet | 10 мин |
| 31 | Extractить `internal/configsafe` (§5.6) | Sonnet | 30 мин |
---
## Статус реализации
Обновлено 2026-08-19 по факту проверки дерева (`main`).
| # | Задача | Статус |
|---|--------|--------|
| 1 | CSS для DMARC-страниц | сделано |
| 2 | SPF CIDR | не требовалось — `coversAny()` уже разбирает CIDR |
| 3 | `io.LimitReader` в DMARC ingest/parse | сделано |
| 4 | Вытеснение bucket'ов | сделано; плюс потолок роста карты заблокированных |
| 5 | Логирование fail-open auth IP | не требовалось — лог уже был |
| 6 | Мёртвый код в demotion guard | сделано |
| 7 | Flash-сообщения в настройках | сделано |
| 8 | `.check-row` | сделано иначе: класс определён в `panel.css` |
| 9 | `internal/atomicfile` | сделано |
| 10 | `internal/supervisor` | сделано |
| 11 | Объединение rate-limit парсеров | сделано; обёртки-заглушки удалены |
| 12 | Plan-файлы / CHANGELOG | сделано |
| 13 | `schema-migrations.md` | сделано: заголовок `v10`, битые ссылки убраны |
| 14 | Ссылки на spec в build/deploy | сделано: `build/`, `deploy/`, миграции, workflow |
| 15 | `dmarc-ingest` symlink | не требовалось — уже описан в `architecture.md` |
| 16 | Ссылки в roadmap | сделано |
| 17 | Типизированные template data structs | **не сделано** → `template-data-typing` |
| 18 | Миграция на `log/slog` | **не сделано** → `structured-logging` |
| 19 | Build tags для `machine.go` | отклонено, см. ниже |
| 20 | Guide URL → `{{.SourceURL}}` | сделано |
| 21 | Тесты DMARC service layer | **не сделано** → `review-2026-08-followups` |
| 22 | Тесты хендлеров | **не сделано** → `review-2026-08-followups` |
| 23 | postfix/dmarc injection cases | сделано |
| 24 | `validate_email_test` | сделано |
| 25 | Тесты error pages / битого бэкапа | **не сделано** → `review-2026-08-followups` |
| 26 | CSP `unsafe-inline` | не требовалось — директивы в CSP нет |
| 27 | bcrypt cost 12 | сделано, включая setup-аккаунт (`auth.BcryptCost`) |
| 28 | Re-auth перед бэкапом | **не сделано** → `review-2026-08-followups` |
| 29 | Accessibility help drawer | сделано |
| 30 | Ограничение `postqueue -p` | сделано: таймаут 5 с + лимит вывода 4 МиБ |
| 31 | `internal/configsafe` | сделано |
**Отклонено с обоснованием.** §7.3 (задача 19): чтение `/proc` параметризовано
корнем каталога и не содержит платформенного кода, его тесты разбирают
подставные деревья и проходят на любой ОС, а отсутствие `/proc` уже
обрабатывается штатно (`TestMachineSamplerWithoutProc`). `//go:build linux`
не даёт ни выигрыша при сборке, ни в рантайме, но убирает эти тесты на машине
разработчика.
**Открытый вопрос.** §4.4: решение «добавить per-session CSRF-токен» записано,
но задачи под него в таблицах фаз нет, и токенов в коде нет. Нужно либо
реализовать токены, либо вернуть §4.4 в статус осознанного компромисса и
оставить это решение в одном месте — в `security.md`.
**Вне фазовых таблиц** (в план не переносились и не делались): §5.5, §6.1,
§6.2, §6.4, §7.4, §8.6, §10.3, §10.4, §10.5, а также §9.3–9.4 сверх задач
23–25.
---
## Что перенесено в roadmap
Всё незакрытое заведено в [roadmap.md](../roadmap.md) — этот файл больше не
единственное место, где живут остатки:
| Пункт плана | Элемент roadmap |
|---|---|
| §4.4 CSRF-токен | [csrf-tokens](../roadmap.md#csrf-tokens) |
| Задача 17 (§7.1) | [template-data-typing](../roadmap.md#template-data-typing) |
| Задача 18 (§7.2), §5.5 | [structured-logging](../roadmap.md#structured-logging) |
| Задачи 21, 22, 25, 28 и §6.1, §6.2, §6.4, §7.4, §8.6, §9.3, §10.3–10.5 | [review-2026-08-followups](../roadmap.md#review-2026-08-followups) |
| Задача 19 (§7.3) | не переносится — отклонено, обоснование выше |
+3 -1
View File
@@ -218,7 +218,9 @@ flowchart LR
## Dependencies
- [inbound-relay.md](inbound-relay.md) (shipped).
- Inbound relay, shipped in `[1.4.0]` — as built in
[architecture.md](../architecture.md) § Mail path. Its plan file was removed
once it shipped.
## Model routing (this plan)
+4 -2
View File
@@ -43,7 +43,8 @@ attack/backup surface — closer to "mini-mailbox" than pure relay.
## Context (as-built)
- Inbound relay forwards or rejects; no local delivery ([inbound-relay.md](inbound-relay.md)).
- Inbound relay forwards or rejects; no local delivery
([architecture.md](../architecture.md) § Mail path).
- Optional `INBOUND_ANTISPAM_MILTER` — external rspamd may quarantine on its
own today, without panel integration.
- DMARC `p=quarantine` is unrelated (outbound policy for receivers).
@@ -56,7 +57,8 @@ attack/backup surface — closer to "mini-mailbox" than pure relay.
## Dependencies
- [inbound-relay.md](inbound-relay.md) (shipped).
- Inbound relay, shipped in `[1.4.0]` — as built in
[architecture.md](../architecture.md) § Mail path.
- Sensible to decide **after** or **alongside**
[inbound-antispam-panel.md](inbound-antispam-panel.md) (journal/lists), but
not blocked on it for design discussion.
+71 -63
View File
@@ -35,7 +35,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
| csrf-tokens | Per-session CSRF tokens on state-changing forms | candidate | — | — |
| template-data-typing | Typed structs for template data instead of `map[string]any` | candidate | — | — |
| structured-logging | `log/slog` with levels and fields | candidate | — | — |
| review-2026-08-followups | Remaining findings of the 2026-08-19 code review | candidate | — | [plans/code-review-2026-08.md](plans/code-review-2026-08.md) |
| review-2026-08-followups | Remaining findings of the 2026-08-19 code review | candidate | — | — |
| schema-squash | Squash SQLite migrations into a 2.x baseline | **2.x** | — | — |
**Recommended order** (not binding): the next feature is
@@ -46,13 +46,14 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
queue-retries in `[1.3.1]`; the 2026-08-13 full-tree review follow-ups are in
`[1.3.0]`. Candidates need explicit agreement before they join the queue.
The 2026-08-19 code review ([plans/code-review-2026-08.md](plans/code-review-2026-08.md))
is closed except for four items, all listed above and none of them urgent:
[csrf-tokens](#csrf-tokens), [template-data-typing](#template-data-typing),
The 2026-08-19 full-tree code review is closed except for four items, all
listed above and none of them urgent: [csrf-tokens](#csrf-tokens),
[template-data-typing](#template-data-typing),
[structured-logging](#structured-logging) and
[review-2026-08-followups](#review-2026-08-followups). What that review
already produced is in [CHANGELOG.md](../CHANGELOG.md) `[Unreleased]`; the
per-task status table is at the end of its plan file.
produced is in [CHANGELOG.md](../CHANGELOG.md) `[Unreleased]`; its own document
was removed once these four items carried everything still open, and stays in
git history.
After a context reset, pick an item marked `agreed` or `in progress`, then work
the **Implementation checklist** in its linked plan. The `Progress` column above
@@ -178,24 +179,27 @@ falls back to `postmaster@SELFPOST_HOSTNAME`.
## csrf-tokens
**Goal:** a per-session CSRF token on every state-changing form, checked
server-side in addition to the `Sec-Fetch-Site` / `Origin` checks the panel
relies on today.
server-side in addition to the `Sec-Fetch-Site` / `Origin` check the panel
relies on today (`originAllowed` in
[internal/web/security.go](../internal/web/security.go)).
**Boundary:** defence in depth, not a replacement — the header checks stay and
keep covering requests that carry no form. The token is bound to the existing
**Boundary:** defence in depth, not a replacement — the header check stays and
keeps covering requests that carry no form. The token is bound to the existing
session record; no new session model, no cookie changes.
**Done when:** every POST form and htmx fragment that changes state carries a
token, a missing or mismatched token is refused with a test that proves it, and
[security.md](security.md) describes the layered model instead of arguing that
the header checks alone are sufficient.
[security.md](security.md) describes the layered model in one place instead of
two.
**Dependencies / risks:** the current documented decision in
[security.md](security.md) is that header checks suffice; this item exists
because the 2026-08-19 review recorded the opposite decision without ever
turning it into a task (§4.4). Confirm which of the two stands before writing
code — the honest outcomes are "implement tokens" or "keep headers and say so
in one place only". Token plumbing through htmx swaps is where the work is.
**Dependencies / risks:** [security.md](security.md) currently records the
opposite decision — origin checking without tokens, with the accepted risk and
its revisit triggers written out in the ADR § *CSRF via origin checking*. The
2026-08-19 review recorded "add tokens" without turning it into a task, which is
why this item exists. Settle which decision stands before writing code; both
outcomes are honest, and "keep headers" means deleting this item rather than
leaving the contradiction standing. The work itself is a hidden field in roughly
two dozen forms plus the htmx swap paths.
**Version:** `1.x` MINOR; `candidate`.
@@ -203,22 +207,26 @@ in one place only". Token plumbing through htmx swaps is where the work is.
## template-data-typing
**Goal:** replace `map[string]any` template payloads in `internal/web/handlers`
with typed structs (`DomainDetailData`, `SettingsData`, …), so a renamed or
mistyped key is a compile error instead of a silently empty page section.
**Goal:** replace the `map[string]any` template payloads in
`internal/web/handlers` with typed structs (`DomainDetailData`, `SettingsData`,
...), so a renamed or mistyped key is a compile error instead of a silently
empty page section.
**Boundary:** handler-to-template plumbing only; no template redesign, no route
or behaviour change. `renderDomainDetail` (~40 keys) is the worst case and the
one that motivates the item.
or behaviour change. `renderDomainDetail`
([handlers_apps.go](../internal/web/handlers/handlers_apps.go)) sets around
forty keys on one map and is the case that motivates the item; roughly ten
render sites still pass maps.
**Done when:** the page handlers pass structs, the shared footer/base fields
still arrive on every page, and the template guard tests pass unchanged.
(`Version`, `Copyright`, `SourceURL`) still arrive on every page, and the
template guard tests pass unchanged.
**Dependencies / risks:** mechanical but wide — it touches every
`handlers_*.go` and every template that reads those keys; a missed key is
invisible unless a test renders that page. Worth pairing with the handler
tests in [review-2026-08-followups](#review-2026-08-followups) so the pages
are covered before they are rewired. Review estimate: 2–3 hours (§7.1).
`handlers_*.go` and every template that reads those keys, and a missed key is
invisible unless a test renders that page. Worth pairing with the handler tests
in [review-2026-08-followups](#review-2026-08-followups) so the pages are
covered before they are rewired. Reviewer's estimate: 2–3 hours.
**Version:** no bearing on semver; `candidate`.
@@ -227,20 +235,20 @@ are covered before they are rewired. Review estimate: 2–3 hours (§7.1).
## structured-logging
**Goal:** move from `log.Printf` to `log/slog` — levels, structured fields, and
one logger injected through constructors instead of a package-level `logf` in
five packages (§5.5, §7.2).
one logger injected through constructors, instead of the package-level
`logf = log.Printf` alias repeated in five packages.
**Boundary:** internal logging only. The log destination stays stdout/stderr
under supervisord; no log shipping, no new dependency, and the operator-visible
lines the Status page and the log tailer parse must keep parsing.
**Boundary:** internal logging only. The destination stays stdout/stderr under
supervisord; no log shipping, no new dependency. Lines the panel's own Status
page and log tailer depend on must keep parsing.
**Done when:** packages take a `*slog.Logger`, the per-package `logf = log.Printf`
aliases are gone, levels are used deliberately (an error is not an info), and
the panel's own journal/log-tailer expectations still hold.
**Done when:** packages take a `*slog.Logger`, the per-package `logf` aliases
are gone, and levels are used deliberately (an error is not an info).
**Dependencies / risks:** the log tailer reads Postfix's log, not the panel's,
so the risk is contained — but anything that greps the panel's output (e2e,
operator habits) sees a new format. Review estimate: 3–4 hours (§7.2).
**Dependencies / risks:** the log tailer reads Postfix's log rather than the
panel's, so the blast radius is small — but anything that greps the panel's
output (e2e assertions, operator habits) sees a new format. Reviewer's
estimate: 3–4 hours.
**Version:** no bearing on semver; `candidate`.
@@ -248,39 +256,39 @@ operator habits) sees a new format. Review estimate: 3–4 hours (§7.2).
## review-2026-08-followups
**Goal:** the leftovers of the 2026-08-19 code review that are too small to be
roadmap items of their own. The full findings and the per-task status table are
in [plans/code-review-2026-08.md](plans/code-review-2026-08.md).
**Goal:** the leftovers of the 2026-08-19 full-tree code review that are too
small to be roadmap items of their own. Everything still open from that review
is in the table below; the rest of it shipped in
[CHANGELOG.md](../CHANGELOG.md) `[Unreleased]`.
| § | Item |
| Area | Item |
|---|---|
| §4.7 | Re-authenticate (current password) before a backup download |
| §9.2 | Tests for the DMARC service layer (`service.go`, `addresses.go`) |
| §9.2 | Tests for the apps / domains / users / dmarc handlers |
| §9.4 | Tests: error page templates, corrupted backup archive, delivery-log pagination edges |
| §9.3 | Thin spots: `store/stats_test` zero-traffic case, concurrent store operations |
| §6.1 | `secretfile` accepts an empty password at library level — guard or document |
| §6.2 | Log-rotation fingerprint collision in `logtail/offset.go` — include the inode |
| §6.4 | `parsePage` does not clamp to `lastPage` |
| §7.4 | `dnscheck` resolver fans out four parallel queries instead of falling back — decide or document |
| §8.6 | `dev/workflow.md` still names an outdated base version |
| §10.3 | `.pair` vs `.split`: two class names for one layout |
| §10.4 | Two `back_link` elements in `dmarc_domain.html` |
| §10.5 | `initShowWhen` is not re-run after an htmx swap (safe today, fragile) |
| Security | Re-authenticate (ask for the current password) before a backup download — [handlers_backup.go](../internal/web/handlers/handlers_backup.go) hands the archive to any signed-in session |
| Tests | The `internal/dmarc` service layer (`service.go`, `addresses.go`) has no tests |
| Tests | No direct tests for the apps / domains / users / dmarc handlers; `authz_test` covers them only indirectly |
| Tests | Nothing covers error page templates, a corrupted backup archive, malformed or oversized DMARC XML, delivery-log pagination edges, concurrent store operations, or zero-traffic `store` stats |
| Edge case | `internal/secretfile` accepts an empty password at library level — guard it, or state that validation is the caller's job |
| Edge case | The log fingerprint in [logtail/offset.go](../internal/logtail/offset.go) can collide across a rotation — include the inode |
| Edge case | `parsePage` in [handlers_monitor.go](../internal/web/handlers/handlers_monitor.go) does not clamp to `lastPage` |
| Edge case | [dnscheck/resolver.go](../internal/dnscheck/resolver.go) fans out four parallel queries where a fallback chain would do — change it or write down why not |
| UI | `.pair` (DMARC templates) and `.split` (every other page) are two names for one layout |
| UI | Two `back_link` elements in a row in `dmarc_domain.html` |
| UI | `initShowWhen` is not re-run after an htmx swap — safe today (no form arrives that way), fragile if one ever does |
**Boundary:** no feature work and no behaviour change an operator would notice,
apart from the backup re-auth prompt. Anything here that grows past an hour
becomes its own roadmap item instead.
**Done when:** each row is either implemented or struck with a written reason,
and the status table in the plan file says which.
**Done when:** each row is either implemented or struck with a written reason.
**Dependencies / risks:** the test rows overlap
[template-data-typing](#template-data-typing) — write the handler tests first
if both are taken up. §7.3 (build tags for the `/proc` reader) was **declined**
during the review pass and is deliberately absent: that code is parameterised by
root, has no platform-specific calls, already degrades gracefully without
`/proc`, and tagging it `linux` would only delete cross-platform test coverage.
[template-data-typing](#template-data-typing) — write the handler tests first if
both are taken up. One finding of that review was **declined** and is
deliberately absent: `//go:build linux` for the `/proc` reader in
[internal/health/machine.go](../internal/health/machine.go). That code is
parameterised by root directory, calls nothing platform-specific, already
degrades gracefully when `/proc` is missing (`TestMachineSamplerWithoutProc`),
and tagging it would only delete cross-platform test coverage.
**Version:** no bearing on semver; `candidate`.
+4 -1
View File
@@ -148,7 +148,10 @@ deferred item from the roadmap.
(see the ADR below). A token would not save the panel from XSS inside it
either: code executing in the panel's origin sends the request itself —
against that, `html/template` auto-escaping and CSP do the work, which is why
templates must contain no inline scripts and no inline styles.
templates must contain no inline scripts and no inline styles. Tracked as
[csrf-tokens](roadmap.md#csrf-tokens): implementing that item replaces this
bullet and the ADR below, and dropping it leaves them as the single record of
the decision. The two must not disagree.
- **Destructive-action confirmation (`data-confirm`) is JavaScript-only.**
Delete, regenerate-password, and clear-rate-limit forms carry a
`data-confirm` prompt handled entirely in