test / test (push) Canceled after 0s
A message's From: line is held against the application that sends it, under the rule Postfix holds the envelope sender to. OpenDKIM chooses the key by that line, so until now an application could have mail signed as another domain of the server or as the server itself. The check (internal/milter/sender.go) - A role of the panel on a socket of its own (SENDER_CHECK_SOCKET), first in smtpd_milters and strict: default_action=tempfail. The journal milter keeps its socket and stays fail-open. postfix-wrapper waits for the new socket. - It reads no database: what an application may send as comes from Postfix's own sender_login_maps, what the server signs for from OpenDKIM's SigningTable. Both are read again when they change; one that cannot be read defers mail. - One From: field, one address, allowed by the envelope's lookup; then no other name of the SigningTable may appear anywhere in the raw line. Refused: 550 5.7.1 naming the line, reduced to printable ASCII. - A refusal is a send-log row with reason "from" and the header address (migration 0006), written by another goroutine and not waited for. The journal's refusals record their reason too (limit, client), and the notifier takes only a limit's. What it costs (docs/plans/from-header-binding.md, Statistics) - The role marks itself alive every 2 s; at start the gap since the mark is downtime, counted from Postfix's start if later and not at all when supervisord says Postfix is not running. - The log tailer counts the connections Postfix turned away: a warning that names the socket, then milter-reject "Service unavailable" from the same smtpd. Seen on the stand: the client is refused from CONNECT on and leaves at AUTH (454), so no MAIL line is ever written. - sender_check_days, a row per UTC day, kept a year. Pages - Health: the card "Sender check", the socket's row, and the cards layout - boxes numbered by importance in two stacks read in rows; the left stack polls, the three readings of the right one are swapped in by id. - Outbound log: a refused row says why in its tag (wrong From, wrong client, rate-limited); the message's page shows the From: line and what the application may send as. - Help: topic "Sender check", word for word the mockup's. Found by the stand, fixed here: the names for the second look came from the sender bindings, and a domain with a key and no application yet was not among them. e2e: negative_journal_milter_fail_open is gone - it proved that mail passes with the panel down, which is no longer so. In its place sender_check_holds_the_from_line (another domain's From:, the hostname's, a stranger's, an address off the list, another sender in the display name - refused; own addresses - delivered) and panel_down_defers_mail (4xx with the panel stopped, the container alive, mail again after, downtime and one deferred message on Health, the card warning). Test server, this tree before the last cosmetic edits (a fixture row, a fact's size, docs): go vet ok, go test 23 packages ok, make e2e ok (337 s). Locally after them: internal/web/view ok. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
554 lines
28 KiB
Markdown
554 lines
28 KiB
Markdown
# Roadmap: open work (1.x+)
|
||
|
||
**Status:** a working tracker of extensions to the v1.0 boundary, each taken up
|
||
only after explicit agreement ([product.md](product.md),
|
||
[.cursor/rules/agent-rules.mdc](../.cursor/rules/agent-rules.mdc)). Detailed
|
||
design lives in [plans/](plans/). Items marked `candidate` need an OK before
|
||
any code is written.
|
||
|
||
**Reading this from outside the project:** nothing here is a commitment or a
|
||
release promise. There are no dates, the order is a recommendation rather than
|
||
a schedule, and an item can be dropped or reshaped once its plan is written.
|
||
What the project *will not* do is a separate question, answered in
|
||
[product.md](product.md) — an item's absence from this file does not mean it is
|
||
planned but unlisted.
|
||
|
||
**Versioning:** SemVer MINOR in the **1.x+** line by default (`1.1.0`…), as long
|
||
as defaults and migrations stay compatible with `1.0.0`. A major `2.x` only for
|
||
an explicit break. One such break, when 2.x is cut for any reason, is
|
||
[schema-squash](#schema-squash) — replacing the 1.x SQLite migration chain
|
||
with a baseline. That item does not by itself justify a major.
|
||
|
||
**Process:** [development.md](development.md). The history of closed phases is
|
||
in `git log` and [CHANGELOG.md](../CHANGELOG.md).
|
||
|
||
---
|
||
|
||
## Index
|
||
|
||
| ID | Topic | Status | Progress | Plan |
|
||
|---|---|---|---|---|
|
||
| preflight | Installation check page (`/preflight`) | candidate | — | [plans/preflight.md](plans/preflight.md) |
|
||
| contributing | `CONTRIBUTING.md` | candidate | — | — |
|
||
| inbound-antispam-panel | Inbound antispam journal + allow/deny lists | shipped `2.1.0` | 12/12 | [plans/inbound-antispam-panel.md](plans/inbound-antispam-panel.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 | — | — |
|
||
| panel-notifications | E-mail notifications about important events | shipped `2.3.0` | 10/10 | [plans/panel-notifications.md](plans/panel-notifications.md) |
|
||
| delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | shipped `2.2.0` | 8/8 | [plans/delivery-log-storage.md](plans/delivery-log-storage.md) |
|
||
| password-reset | Password reset by e-mail | candidate | — | — |
|
||
| phone-desktop-mode | A phone browser in "desktop site" mode gets the phone navigation | candidate | — | — |
|
||
| from-header-binding | Hold a message's `From:` header against the application that sends it | in progress, for `2.4.0` | 7/9 | [plans/from-header-binding.md](plans/from-header-binding.md) |
|
||
| panel-redesign | Panel redesign (accepted mockups, design contract) | shipped `2.0.0` | 27/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |
|
||
| schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [plans/panel-redesign.md](plans/panel-redesign.md) |
|
||
|
||
**Recommended order** (not binding; owner, 2026-10-09: feature order follows
|
||
development convenience): **panel-redesign** shipped as `2.0.0` (2026-10-11) and
|
||
**inbound-antispam-panel** as `2.1.0` (2026-10-12), built in the new component
|
||
kit — styling its two screens once instead of twice. **delivery-log-storage**
|
||
shipped as `2.2.0` the same day, and **panel-notifications** as `2.3.0`.
|
||
Nothing is agreed after it: every remaining
|
||
item is a candidate. panel-docs shipped in
|
||
[CHANGELOG.md](../CHANGELOG.md) `[1.8.0]`; dmarc-reports in `[1.7.0]`
|
||
(its ingest path first ran in `2.0.0` and was reviewed on 2026-10-11, see
|
||
[security.md](security.md)); domain-stats-auto-ratelimit in
|
||
`[1.6.0]`; send-log-retention in `[1.5.0]`; inbound-relay in `[1.4.0]`;
|
||
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 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
|
||
produced is in [CHANGELOG.md](../CHANGELOG.md) `[1.9.2]`; 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
|
||
is `done/total` checklist steps in that plan ([development.md](development.md)
|
||
§ Plan checklists).
|
||
|
||
---
|
||
|
||
## contributing
|
||
|
||
**Goal:** `CONTRIBUTING.md` in the root — the dev loop, the checks to run
|
||
before a PR, the commit protocol; [development.md](development.md) links to it
|
||
rather than repeating it.
|
||
|
||
**Boundary:** process documentation; worth writing once there is an external
|
||
flow of PRs.
|
||
|
||
**Done when:** the file is in the root and development.md does not duplicate
|
||
it.
|
||
|
||
**Dependencies / risks:** with a single developer and no PRs, this is low
|
||
priority.
|
||
**Version:** no bearing on semver.
|
||
|
||
---
|
||
|
||
## panel-docs
|
||
|
||
**Status:** done — shipped in `[1.8.0]` (2026-08-18). The implementation plan
|
||
file was removed as shipped cleanup; release details remain in
|
||
[CHANGELOG.md](../CHANGELOG.md).
|
||
|
||
**Goal:** built-in operator documentation in the panel — short pages (or a
|
||
help drawer) that explain what each Status check and other controls mean,
|
||
without sending the operator out to `docs/guide.md`.
|
||
|
||
**Boundary:** in-panel help only; not a second copy of the full operator guide.
|
||
Seed content includes the Status blurbs removed from the cards in favour of a
|
||
denser layout — Machine (kernel counters / rate window), TLS certificate
|
||
(port 465, reverse-proxy mount), Hostname / reverse DNS (forward-confirmed
|
||
reverse DNS, PTR at the hosting provider), and similar notes for other panel
|
||
surfaces as they lose inline commentary.
|
||
|
||
**Done when:** an operator can open help from the panel for those topics; the
|
||
removed Status blurbs are preserved there (or equivalent); no requirement to
|
||
read the git tree for day-to-day meaning of a card.
|
||
|
||
**Dependencies / risks:** copy ownership and translation; keeping help in sync
|
||
when checks change; not bloating every page with a second column of prose.
|
||
|
||
**Version:** shipped `1.8.0`.
|
||
|
||
---
|
||
|
||
## inbound-antispam-panel
|
||
|
||
**Goal:** panel view of inbound anti-spam decisions (when, from, to, subject,
|
||
action) plus editable allow/deny lists that tune the external filter — without
|
||
shipping rspamd or another engine inside the image.
|
||
|
||
**Boundary:** metadata journal and list sync only; SelfPost does not run the
|
||
filter ([product.md](product.md)). Quarantine is not on the roadmap
|
||
([Ideas](#ideas-not-on-the-roadmap)). Design closed in
|
||
[plans/inbound-antispam-panel.md](plans/inbound-antispam-panel.md) § Decisions.
|
||
|
||
**Done when:** with inbound relay enabled, the operator sees journal rows
|
||
(accepts via inbound-journal milter, rejects via mail.log tailer) and can
|
||
maintain instance-wide allow/deny lists synced to rspamd maps when the antispam
|
||
hook is set.
|
||
|
||
**Dependencies / risks:** inbound relay `[1.4.0]`; rspamd map/header coupling;
|
||
journal PII; global-admin-only RBAC.
|
||
|
||
**Version:** shipped `2.1.0` (2026-10-12), MINOR (was `1.10.0`; reordered after the redesign on 2026-10-09).
|
||
|
||
---
|
||
|
||
## preflight
|
||
|
||
**Goal:** a `/preflight` page (global-admin only) that verifies instance-level
|
||
infrastructure: rDNS/PTR, TLS certificate + hostname match, port 25/465/587
|
||
reachability, HELO banner correctness, reverse-proxy headers, DKIM milter
|
||
socket, and a test-email sender. Each check shows an actionable fix
|
||
recommendation on failure.
|
||
|
||
**Boundary:** instance infrastructure only; per-domain DNS checks remain on
|
||
domain detail pages. Does not track test-email delivery beyond local MTA
|
||
acceptance.
|
||
|
||
**Done when:** the page loads, all checks run with traffic-light results and
|
||
recommendations, and the test-email form successfully submits a message through
|
||
Postfix.
|
||
|
||
**Dependencies / risks:** port-connectivity check may not reflect external
|
||
reachability behind NAT without hairpin; test email needs at least one domain or
|
||
falls back to `postmaster@SELFPOST_HOSTNAME`.
|
||
|
||
**Version:** `1.x` MINOR; `candidate`.
|
||
|
||
---
|
||
|
||
## csrf-tokens
|
||
|
||
**Goal:** a per-session CSRF token on every state-changing form, checked
|
||
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 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 in one place instead of
|
||
two.
|
||
|
||
**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`.
|
||
|
||
---
|
||
|
||
## template-data-typing
|
||
|
||
**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`
|
||
([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
|
||
(`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, 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`.
|
||
|
||
---
|
||
|
||
## structured-logging
|
||
|
||
**Goal:** move from `log.Printf` to `log/slog` — levels, structured fields, and
|
||
one logger injected through constructors, instead of the package-level
|
||
`logf = log.Printf` alias repeated in five packages.
|
||
|
||
**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` aliases
|
||
are gone, and levels are used deliberately (an error is not an info).
|
||
|
||
**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`.
|
||
|
||
---
|
||
|
||
## review-2026-08-followups
|
||
|
||
**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) `[1.9.2]`.
|
||
|
||
| Area | Item |
|
||
|---|---|
|
||
| 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~~ — struck: retired by [panel-redesign](#panel-redesign); both stylesheets are gone and every page is laid out by the component kit |
|
||
| UI | ~~Two `back_link` elements in a row in `dmarc_domain.html`~~ — struck: retired by [panel-redesign](#panel-redesign); the page is rebuilt on the kit with one breadcrumb in its head |
|
||
| UI | ~~`initShowWhen` is not re-run after an htmx swap~~ — struck: the show/hide rules listen on the document and are applied again on `htmx:load` |
|
||
|
||
**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.
|
||
|
||
**Dependencies / risks:** the test rows overlap
|
||
[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`.
|
||
|
||
---
|
||
|
||
## delivery-log-storage
|
||
|
||
**Problem (owner, 2026-10-09):** the *Delivery log* box on a message's page
|
||
(`/deliveries/{id}`, mockup `out-message`) is usually empty. The lines are not
|
||
stored anywhere: `logtail.QueueLines`
|
||
([internal/logtail/logtail.go](../internal/logtail/logtail.go)) greps the
|
||
**current** `mail.log` on every page view, within its last 4 MB, and never
|
||
opens the rotated files. Rotation is daily
|
||
([build/logrotate-mail.conf](../build/logrotate-mail.conf)), so a message
|
||
older than the last rotation — in practice anything not sent today — shows
|
||
"Nothing for this queue id in the current mail log" while its send-log row
|
||
lives on for ninety days. The fourteen rotated files on disk hold the lines
|
||
and are never read.
|
||
|
||
**Goal:** a message's page shows what Postfix wrote about it for as long as
|
||
the message is in the send log.
|
||
|
||
**Options:**
|
||
|
||
1. **Store the lines** (recommended). The log tailer already reads every
|
||
`mail.log` line once to update delivery status; a line whose queue id
|
||
belongs to a send-log row is also appended to a new table
|
||
(`send_log_lines`: row id, written at, text), five to ten short lines per
|
||
message. Retention follows the row — the existing send-log sweep deletes
|
||
them together — and the page reads the table instead of the file. Lines
|
||
written while the panel was down are caught up the way status updates
|
||
are, from the persisted read offset. The grep stays only as a fallback
|
||
for rows older than the table.
|
||
2. **Grep the rotated files too.** `QueueLines` walks `mail.log.1`,
|
||
`mail.log.2.gz`, … until it finds the id. No schema, but each view
|
||
decompresses up to fourteen files, and past fourteen days the answer is
|
||
still empty against a ninety-day row.
|
||
|
||
**Boundary:** outbound send-log rows only; the inbound journal
|
||
([inbound-antispam-panel](#inbound-antispam-panel)) decides for itself
|
||
whether it wants the same. No change to what the box looks like — the
|
||
`out-message` mockup already draws it as a `log_pane`.
|
||
|
||
**Done when:** a message sent before the last rotation shows its lines on its
|
||
page; the lines disappear with the row; the page wording no longer blames
|
||
rotation for an ordinary case.
|
||
|
||
**Dependencies / risks:** a queue id is reused by Postfix after a message
|
||
leaves the queue, so matching must be bounded to the row's lifetime (accepted
|
||
time to final status plus the queue lifetime), not to the id alone; the table
|
||
grows with the send log (small — a few hundred bytes per message) and is
|
||
covered by the same retention.
|
||
|
||
**Version:** shipped `2.2.0` (2026-10-12), MINOR; option 1. Decisions and
|
||
checklist: [plans/delivery-log-storage.md](plans/delivery-log-storage.md).
|
||
|
||
---
|
||
|
||
## panel-notifications
|
||
|
||
**Goal:** the panel tells its administrators when something needs them instead
|
||
of waiting to be opened. First version, four events: a sending domain's DNS
|
||
record stops matching, a level-2 rate limit refuses mail, the TLS certificate
|
||
is about to expire, a message has been in the queue for more than three days.
|
||
|
||
**Boundary:** e-mail only, from `postmaster@<hostname>`, submitted locally -
|
||
not from one of the sending domains. One checkbox per user under **Account**
|
||
(no choice per event), one instance switch under **Server › Settings**; a
|
||
domain administrator hears only about assigned domains. No streams, no
|
||
digests, no webhooks, no chat integrations. Not in the first version: a
|
||
process or milter down, an inbound upstream unreachable, DMARC failures.
|
||
|
||
**Done when:** each of the four events reaches the account e-mail of every
|
||
user who has notifications on and may see its subject, by the rules of the
|
||
plan (a "cleared" message for DNS and the queue only); a user without an
|
||
e-mail gets nothing; the switch turns it all off.
|
||
|
||
**Dependencies / risks:** the account e-mail (2.0 baseline, see
|
||
[plans/panel-redesign.md](plans/panel-redesign.md) § Account e-mail); a relay
|
||
that cannot send cannot report that it cannot send - none of the four events
|
||
is "the relay is down"; mail from the bare hostname needs an SPF record for
|
||
it to be believed, which Server › Health will check; event state must survive
|
||
restarts so a reboot does not re-announce everything. The screens are not
|
||
drawn: the Notifications box was taken out of `account` and `settings` on
|
||
2026-10-10 and is redrawn as the first step.
|
||
|
||
**Version:** shipped `2.3.0` (2026-10-12), MINOR - the design questions were closed
|
||
with the owner on 2026-10-12
|
||
([plans/panel-notifications.md](plans/panel-notifications.md)) and he took it into
|
||
work the same day.
|
||
|
||
---
|
||
|
||
## password-reset
|
||
|
||
**Goal:** a user who forgot their password gets a one-time link at their
|
||
account e-mail instead of asking the global administrator to set a new one.
|
||
|
||
**Boundary:** a link on the sign-in page, a short-lived single-use token, the
|
||
same answer whether or not the username exists. The only global administrator
|
||
without a working e-mail still recovers from the container (today's path).
|
||
|
||
**Done when:** the flow works end to end, tokens expire and cannot be reused,
|
||
requests are rate-limited, and it passes a security review.
|
||
|
||
**Dependencies / risks:** the account e-mail; an account-takeover surface where
|
||
there was none — security review is part of the item, not an afterthought.
|
||
Not drawn yet. Changing the account e-mail must then require the current
|
||
password (2.0 security review), because the e-mail becomes where a reset link
|
||
goes and today Account saves it without the password.
|
||
|
||
**Version:** `2.x` MINOR; `candidate`.
|
||
|
||
---
|
||
|
||
## phone-desktop-mode
|
||
|
||
**Problem (owner, 2026-10-11):** the panel opened on a phone with the
|
||
browser's "desktop site" switched on does not become the desktop panel.
|
||
Seen on Server › Health at `selfpost.mixfed.ru`: the page body is laid out in
|
||
columns (Machine and Processes side by side, three cards in the next row),
|
||
while the navigation is the phone one - not a bar but a stack with every
|
||
section open (Outbound, Inbound, Server, the user menu), filling the first
|
||
screen before the page starts. The content also sits flush against the left
|
||
edge of the screen.
|
||
|
||
**Goal:** find out why, then decide what the panel should be in that mode -
|
||
the desktop panel, or a phone panel that is at least consistent.
|
||
|
||
**Where to look first (a guess, not checked):** "desktop site" on Android
|
||
lays a page out about 980 px wide. Bulma turns the navigation into a bar at
|
||
1024 px and puts columns side by side from 769 px, so 980 px falls between
|
||
the two: desktop columns under a touch navigation. `panel.css` has its own
|
||
breakpoints at 768, 1023 and 1407 px. Check the width the phone really
|
||
reports, why every dropdown is open rather than folded behind the burger,
|
||
and where the left padding went.
|
||
|
||
**Boundary:** the navigation and page gutter between 769 and 1023 px, which
|
||
is also a tablet in portrait and a narrow desktop window - the same defect is
|
||
expected there. A design change is mockup-first
|
||
([plans/panel-redesign.md](plans/panel-redesign.md) § Enforcement).
|
||
|
||
**Done when:** the cause is written down here, and the panel at that width
|
||
has one coherent layout, checked on a phone in "desktop site" mode and in a
|
||
980 px window.
|
||
|
||
**Version:** `2.x` PATCH or MINOR; `candidate`.
|
||
|
||
---
|
||
|
||
## from-header-binding
|
||
|
||
**Problem (security review of panel-notifications, 2026-10-12):** Postfix
|
||
holds the envelope sender against the SASL login
|
||
(`reject_sender_login_mismatch`), and OpenDKIM chooses the signing key by the
|
||
`From:` header. Nothing holds the header against the login. An application
|
||
of one sending domain can therefore write another registered domain - or,
|
||
since 2.3.0, the server's own name - into `From:` and have the message signed
|
||
with that name's key: mail that passes DMARC as a domain the application has
|
||
no right to, or that looks like a notification of the panel.
|
||
|
||
**Agreed with the owner on 2026-10-12** and taken into work
|
||
([plans/from-header-binding.md](plans/from-header-binding.md)). The rule he
|
||
chose is wider than the one first written here: `From:` is held to exactly
|
||
what the envelope is held to - any address of the application's domain, or
|
||
the addresses of its list - and anything else is refused, a stranger's domain
|
||
included. The check is a role of the panel on a strict socket of its own:
|
||
while the panel does not answer, mail is deferred, not accepted unchecked.
|
||
Server › Health counts what that costs, to decide later whether the check
|
||
needs a process of its own.
|
||
|
||
**Done when:** the e2e stand shows an application refused for another
|
||
tenant's `From:`, for the hostname's and for an address off its list, and
|
||
accepted for its own.
|
||
|
||
**Version:** `2.4.0` MINOR; in progress. Until it ships:
|
||
[security.md](security.md) § Accepted risks.
|
||
|
||
---
|
||
|
||
## panel-redesign
|
||
|
||
**Goal:** a panel an operator opens a few times a year and still finds their
|
||
way in — the important things in view, details one or two clicks away, one
|
||
visual language, no page tuning its own width. The design is drawn and
|
||
accepted: 33 screens and a components page under
|
||
[docs/assets/panel-redesign/](assets/panel-redesign/index.html) (Bulma base,
|
||
postmark / numbered heads / perforated edge, icons). Contract and checklist:
|
||
[plans/panel-redesign.md](plans/panel-redesign.md).
|
||
|
||
**Boundary:** templates, one `panel.css`, shared partials, vendored Bulma and
|
||
icons. Navigation regroups by mail direction (Overview · Outbound · Inbound ·
|
||
Server) and paths are renamed to match. **No compatibility is kept** (owner,
|
||
2026-09-21): no redirects, the migration chain is replaced by one baseline
|
||
and 2.0 starts from an empty data directory — the only live installation is
|
||
the owner's. Inbound domains become delegable to a
|
||
domain administrator, separately from outbound. Server › Health is built
|
||
with the restyle (decided 2026-10-09). Three things the mockups show keep
|
||
their own decision and are not smuggled in: queue as a table
|
||
(`postqueue -j`), inbound queue, inbound log (rides on
|
||
inbound-antispam-panel) — until then both queues ship the old content in the
|
||
new shell. No CSP change, no JavaScript widgets, no second framework.
|
||
|
||
**Done when:** every page renders with the component skeleton recorded in
|
||
`panel/outlines.json`, the guard tests in the plan are green with no
|
||
allow-list, and each page has an evidence screenshot accepted against its
|
||
mockup.
|
||
|
||
**Dependencies / risks:** an implementer substituting local patches or a look
|
||
of their own — answered by guard tests that land before the templates, a
|
||
`design-first` CI step and evidence-based acceptance (plan § Enforcement).
|
||
Competes with feature work; Bulma is ~680 kB minified, embedded once.
|
||
|
||
**Version:** shipped `2.0.0` (2026-10-11), MAJOR.
|
||
|
||
---
|
||
|
||
## schema-squash
|
||
|
||
**Goal:** when 2.x is cut, stop shipping the 1.x migration chain in the binary
|
||
and replace it with one baseline equal to the schema at the then-current head.
|
||
Fresh 2.x data directories no longer create-then-drop the historical `admin`
|
||
table. Current chain, legacy notes, and gate thresholds:
|
||
[schema-migrations.md](schema-migrations.md) (update that file when migrations
|
||
ship; adjust the squash gate to its head at cut time).
|
||
|
||
**Boundary:** 1.x keeps the full chain so a `1.0.0` data directory still boots.
|
||
Do not delete, rename, or reorder migration files while MINOR compatibility with
|
||
`1.0.0` holds. Git history keeps the old files either way; only the embedded
|
||
set in the 2.x image changes.
|
||
|
||
Restore remains a separate lock: the backup manifest version must match the
|
||
running binary ([architecture.md](architecture.md) § Persistence). It does not
|
||
replace the squash upgrade gate.
|
||
|
||
**Done when:** 2.x embeds a single baseline (plus any 2.x-only migrations after
|
||
it); the gate in [schema-migrations.md](schema-migrations.md) is tested; the
|
||
operator guide says a 2.x image will not open an unfinished 1.x database.
|
||
|
||
**Trigger decided 2026-09-21:** [panel-redesign](#panel-redesign) is the 2.0
|
||
cut, and the owner dropped the upgrade gate with it — the chain is deleted to
|
||
zero and 2.0 starts from an empty data directory. The
|
||
**Boundary** and **Done when** above are superseded on that point by
|
||
[plans/panel-redesign.md](plans/panel-redesign.md) § No compatibility; this
|
||
item ships as a step of that plan, not on its own.
|
||
|
||
**Built 2026-10-09** (panel-redesign stage 1): `internal/store/migrations/`
|
||
holds one baseline, `0001_init.sql`. The gate that survives is a refusal, not
|
||
an upgrade: `store.Open` rejects a database that was not created by 2.x
|
||
(`ErrForeignSchema`, tested), so a 1.x file is never half-read — the "missed
|
||
gate" risk below. See [schema-migrations.md](schema-migrations.md).
|
||
|
||
**Dependencies / risks:** a decided 2.x cut (another breaking change, or an
|
||
explicit major). Squashing the current short chain is not a reason to cut 2.x on
|
||
its own. A missed gate leaves a mid-chain 1.x database silently stuck.
|
||
**Version:** `2.x` major only; not a 1.x item.
|
||
|
||
---
|
||
|
||
## Ideas, not on the roadmap
|
||
|
||
Thoughts kept so they are not lost. They are not candidates: nothing here is
|
||
waiting for an OK, has a version or takes a place in the order above. An idea
|
||
joins the index only when the owner says so.
|
||
|
||
- **Inbound quarantine** (moved here by the owner on 2026-10-12: "an idea to
|
||
think about") - hold suspicious inbound mail, review it in the panel, then
|
||
release it to the upstream or discard it. It would take the relay from
|
||
passing mail on to storing it for a while, against "no mailboxes" in
|
||
[product.md](product.md). Notes and open questions:
|
||
[plans/inbound-quarantine.md](plans/inbound-quarantine.md); the screens
|
||
`in-quarantine` and `in-quarantine-message` are drawn and stay unbuilt.
|