# 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 | |---|---|---|---|---| | health-setup-checks | Server › Health also checks the certificate's name, Postfix's greeting and the trusted proxy (was preflight) | agreed (2026-10-13) | — | — | | test-email | Send a test message from the panel (was preflight) | agreed (2026-10-13) | — | — | | 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) | | origin-strict | Refuse a state-changing request that carries neither `Sec-Fetch-Site` nor `Origin` (was csrf-tokens) | agreed (2026-10-13) | — | — | | backup-reauth | Ask for the account password before a backup or a domain export is downloaded | agreed (2026-10-13) | — | — | | review-2026-08-followups | Remaining findings of the 2026-08-19 code review | closed 2026-10-14 (rows placed in [audit-2026-10-12](audit-2026-10-12.md)) | — | — | | 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 | agreed (2026-10-13) | — | — | | local-time | The panel shows time, and counts its days, in a time zone other than UTC | agreed (2026-10-13; design questions open) | — | — | | phone-desktop-mode | A phone browser in "desktop site" mode gets the phone navigation | agreed (2026-10-13: after stages 1 and 2 of audit-2026-10-12) | — | — | | from-header-binding | Hold a message's `From:` header against the application that sends it | shipped `2.4.0` | 9/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) | | audit-2026-10-12 | Follow-ups of the 2026-10-12 full-tree code audit, seven stages | in progress (stage 0 shipped `2.4.2`; next stages 1+2 → `2.5.0`) | 9/61 | [audit-2026-10-12.md](audit-2026-10-12.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, **panel-notifications** as `2.3.0` and **from-header-binding** as `2.4.0`. Stage 0 of [audit-2026-10-12](audit-2026-10-12.md) shipped as `2.4.2` (2026-10-14). 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: [origin-strict](#origin-strict) (its "add CSRF tokens", decided otherwise on 2026-10-13), template-data-typing (done by `2.0.0`; its last map is a step of [audit-2026-10-12](audit-2026-10-12.md) stage 4), structured-logging ([dropped](#dropped-with-the-condition-for-return) on 2026-10-13) 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). --- ## 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). --- ## health-setup-checks **Decided by the owner on 2026-10-13**, in place of the former candidate *preflight* (a page of its own, Server › Preflight, planned in August before Server › Health existed). Of that plan's eight checks Health already makes three (reverse DNS, certificate validity, the DKIM socket); five were missing. Three of the five go into Health, one becomes [test-email](#test-email), one is not built ([Dropped](#dropped-with-the-condition-for-return)). No second page of checks. **Goal:** three more lines on Server › Health, in the cards that are there: - **The certificate is for this server**: its names include `SELFPOST_HOSTNAME` (TLS certificate card). Today a certificate for another name is "valid". - **Postfix greets with the hostname**: the 220 line on port 25 names `SELFPOST_HOSTNAME`, not `localhost` or a container id (Hostname card). - **The trusted proxy**: the address the panel sees this request from, and whether it took it from `X-Forwarded-For`. With `TRUSTED_PROXY_CIDR` wrong or empty behind a proxy the header is ignored ([internal/web/auth/auth.go](../internal/web/auth/auth.go)), every client has the proxy's address and the sign-in limiter is shared by all of them - silently. **Boundary:** lines in existing cards, each with what to do when it fails; no new card unless the mockup shows the lines do not fit. Mockup-first ([plans/panel-redesign.md](plans/panel-redesign.md) § Enforcement). The mockup `preflight` stays drawn and unbuilt. **Done when:** each line is shown wrong on the e2e stand or in a test with a wrong certificate name, a wrong greeting and an untrusted proxy, and right on the test server. **Version:** `2.x` MINOR; `agreed`. --- ## test-email **Decided by the owner on 2026-10-13**, taken out of the former *preflight*. **Goal:** a global administrator sends a test message from the panel to an address they type, and sees whether Postfix took it; the message then appears in Outbound › Log like any other. Today nobody can send through the server without setting up an application first. **Boundary:** global role only; from `postmaster@SELFPOST_HOSTNAME`, the sender notifications use, or from a sending domain the administrator picks - settled at the mockup. It does not follow the message further than the log does. Where the form lives (Health, or a sending domain's page) is settled at the mockup; mockup-first. **Done when:** the form sends, the row appears in the log, the form is rate-limited and refuses a recipient list, and it passes a security review - a form that sends mail is a new surface. **Version:** `2.x` MINOR; `agreed`. --- ## origin-strict **Decided by the owner on 2026-10-13**, in place of the former candidate *csrf-tokens* (per-session tokens on every form, recorded by the 2026-08-19 review): no tokens; the origin check is made strict instead. **Goal:** `originAllowed` ([internal/web/security.go](../internal/web/security.go)) refuses a state-changing request that carries neither `Sec-Fetch-Site` nor `Origin`. Today such a request is let through, which leaves a client that sends neither header open to a request forged by any site ([security.md](security.md) § Accepted risks). **Boundary:** one branch of one function, its test, and the documents. No template changes, no hidden fields. Before the change, find what posts to the panel without a browser (the e2e stand, scripts) and make it send `Origin`. **Done when:** a `POST` with neither header is refused with a test that proves it, the e2e stand passes, the accepted risk about header-less requests is struck from [security.md](security.md) and its ADR states the strict rule. **Dependencies / risks:** a browser that sends neither header can no longer save anything in the panel - accepted, the panel's users pick their own browsers. Tokens stay a recorded alternative, not a task: the conditions under which they are built are in [security.md](security.md), ADR § *CSRF via origin checking* (**Revisit if**). **Version:** `2.x` PATCH; `agreed`. --- ## backup-reauth **Agreed with the owner on 2026-10-13**, out of [review-2026-08-followups](#review-2026-08-followups). **Problem:** Server › Backup hands the full backup and a domain export to any live session of a global administrator ([handlers_backup.go](../internal/web/handlers/handlers_backup.go)). The archive holds the whole database - panel password hashes, application (SASL) passwords, DKIM keys - so a stolen session cookie, or a panel left open, is a quiet copy of the server. *Encrypt with a password* does not help: the thief chooses that password. **Goal:** both downloads ask for the current account password, checked the way a password change checks it and under the same limiter. **Boundary:** one field in each of the two forms (mockup-first); import is not touched; the `selfpost-backup` CLI is not touched - it runs inside the container. **Done when:** a download without the password or with a wrong one is refused with a test that proves it, attempts are limited, and [security.md](security.md) § Backup says so. **Version:** `2.x` MINOR; `agreed`. --- ## 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~~ — struck: agreed on 2026-10-13 as an item of its own, [backup-reauth](#backup-reauth) | | Tests | ~~The `internal/dmarc` service layer (`service.go`, `addresses.go`) has no tests~~ — struck: a step of audit-2026-10-12 stage 4 (DMARC reports tested where they come in) | | Tests | ~~No direct tests for the apps / domains / users / dmarc handlers; `authz_test` covers them only indirectly~~ — struck: users with stage 2 (`submitUserUpdate`), DMARC with stage 4; apps and domains get theirs when a step changes them - a test without a change finds nothing the e2e does not | | 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~~ — struck: the corrupted archive goes to stage 3, DMARC XML to stage 4, paging edges to stage 4 (`parsePage`), concurrency of the last global to stage 2; error templates, other concurrency and zero-traffic stats are dropped (owner, 2026-10-14) - code no step changes, and a test kept for its own sake finds nothing | | Edge case | ~~`internal/secretfile` accepts an empty password at library level~~ — struck: audit-2026-10-12 stage 3 (M5) | | Edge case | ~~The log fingerprint in `logtail/offset.go` can collide across a rotation — include the inode~~ — struck: agreed on 2026-10-13 as part of a step of [audit-2026-10-12](audit-2026-10-12.md) stage 2b (a log rotated while the panel was down is read to its end) | | Edge case | ~~`parsePage` does not clamp to `lastPage`~~ — struck: audit-2026-10-12 stage 4 | | Edge case | ~~`dnscheck/resolver.go` fans out four parallel queries where a fallback chain would do~~ — struck: finding M14 of [audit-2026-10-12](audit-2026-10-12.md) | | 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. **Closed 2026-10-14** with the owner: every row is struck above, placed in a stage of [audit-2026-10-12](audit-2026-10-12.md) or dropped with its reason. **Dependencies / risks:** 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@`, 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). **The e-mail must be confirmed (owner, 2026-10-13).** A reset link goes only to an address its owner has confirmed; an account whose e-mail is not confirmed has no reset, and recovers as today (the global administrator sets a password, or the container). The panel does not confirm addresses today - `users.email` is whatever was typed - so the item builds that first: saving an e-mail under Account mails a one-time link to it, opening the link marks the address confirmed (a migration: the mark on `users`), changing the address clears the mark, and Account shows which it is. An address typed for a user by the global administrator is unconfirmed until that user opens the link. To settle at the mockup: whether notifications (2.3.0) keep going to an unconfirmed address. **Done when:** the flow works end to end, tokens expire and cannot be reused, requests are rate-limited, an account with an unconfirmed e-mail gets no link and the sign-in page answers the same for it, 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; `agreed` (owner, 2026-10-13: domain administrators to come need it - they are not the owner and should not have to ask him for a new password). Mockup-first; changing the account e-mail asks for the current password in the same cut. --- ## local-time **Agreed with the owner on 2026-10-13.** It came from one cell: *Today* on Health's Sender check card is the UTC day and does not say so. Offered a label ("Today, UTC") or the local day, he chose the local day - which is a decision about the whole panel, not one cell. **Today:** every time the panel shows is UTC and says so (*Time, UTC* in the logs, "UTC" after a date), and everything counted by day - the sender check's days, the delivery-log measurement, the statistics behind the charts, retention - is counted by the UTC day. **Goal:** the panel shows time in a chosen time zone, and "today" is that zone's day. **Where the zone comes from (owner, 2026-10-13):** one setting of the instance, *Time zone* under Server › Settings, UTC by default; it holds for every user and for notification mail. Not the browser's zone: only a script could read it, and the panel works without JavaScript. Not a setting of each user yet - **that is added (the same list under Account, "as the server" by default) when** a user of the panel lives in another zone than the one set for the instance, which the first domain administrator who is not the owner may bring. **What is written beside a time (owner, 2026-10-13):** nothing. The "UTC" labels go - the column is *Time*, a date is a date - and the zone is stated in one place, the setting. His reason: there is nobody to send a line of the panel to yet. **The offset is written where "UTC" is today ("Time, UTC+3", "2026-09-21 16:40 UTC+3") when** times of the panel start to be read by someone who does not know the instance's zone: a second user of the panel, or lines copied out of it to another person. **A trap this leaves, known in advance (owner asked for it to be written down, 2026-10-13):** Server › System log, and the *Delivery log* box on a message's page, show the lines of `mail.log` as Postfix wrote them, each with its own stamp ("Oct 13 14:01:52"). Those stamps are the container's time - UTC unless the container is given a `TZ` - and they are not rewritten. Once the rest of the panel is in the instance's zone and no time carries a label, a message that the Outbound log puts at 17:01 has lines stamped 14:01 on its own page, with nothing on the screen to say why. The plan must close this before the item ships, one way or another: rewrite the stamp when a line is shown, say on those two screens which clock the lines use, or run the container in the instance's zone (which moves `mail.log` itself and what the tailer reads). The guide's section on time must say the same thing as the screens. **How a day is counted (owner, 2026-10-13):** the tables that keep a row per day - `sender_check_days`, and `delivery_log_days` of [audit-2026-10-12](audit-2026-10-12.md) stage 1 - key the row by the date in the instance's zone at the moment of counting, from the release on. Rows counted before it stay UTC days. Changing the zone later leaves the rows already written as days of the old zone, a shift of a few hours in a statistic; accepted - the zone is set once, and rows per hour (which would survive a change) cost a migration for it. The charts and statistics group the log by hour already and need nothing. **Open, to settle at the mockups:** the trap above; which of the screens that show a date show the year. **Boundary:** what the panel shows and how it groups by day. `mail.log`, the database's stored instants and the e2e stay as they are. **Version:** `2.x` MINOR; `agreed`; the plan is written when it is taken up. --- ## 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; `agreed` (owner, 2026-10-13), taken up after stages 1 and 2 of [audit-2026-10-12](audit-2026-10-12.md) - mail shown wrong and the tenant boundary come before layout. --- ## 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**, built and reviewed the same day ([plans/from-header-binding.md](plans/from-header-binding.md); what is not done is listed there under *Not done*). 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:** shipped `2.4.0` (2026-10-12), MINOR. What it costs - the panel's downtime is now mail's - is in [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. --- ## Dropped, with the condition for return Items the owner took off the roadmap. Each names what would bring it back; without that it is not offered again. - **structured-logging** (dropped 2026-10-13) - move the panel from `log.Printf` to `log/slog` with levels and fields, one logger passed through constructors. Dropped because the panel's log has one reader, the owner, by eye in `docker logs`; the change is wide (some 225 call sites), changes no behaviour, and gives a new format to everything that reads the output. **Returns if:** the log goes to an outside collector, or it has to be filtered by level. - **Certificate warnings measured by the certificate's lifetime** (left as it is, owner, 2026-10-14). The panel warns 14 and 3 days before a certificate expires, whatever its lifetime ([notify.go](../internal/notify/notify.go), `certFirstDays`, `certLastDays`). A certificate issued for less than 14 days is inside the first warning from its first hour, and each renewal of it is a new certificate and a new message. Not reachable with the 90-day certificate the instance has. **Returns if:** the instance takes a certificate that lives less than 30 days. The rule then offered: the first warning at a quarter of the lifetime left, the last at a tenth, and never earlier than the 14 and 3 days of today - so a longer certificate, a yearly one included, is warned about exactly as now. - **contributing** (dropped 2026-10-13) - a `CONTRIBUTING.md` in the root: the dev loop, the checks before a PR, the commit protocol. Dropped because there is one developer and no PRs, and everything the file would say is in [development.md](development.md); a second file is a second copy to keep in step. **Returns if:** the first outside PR arrives, or the owner decides to invite contributors. - **A check that ports 25, 465 and 587 are reachable** (dropped 2026-10-13, from the former *preflight*). The panel can only dial its own public address from inside the container: behind NAT without hairpin that shows an open port as closed, and with hairpin it shows as open a port the provider blocks from outside. A card that is wrong both ways is worse than none, and an honest check needs a probe outside the server, which is outside the product ([product.md](product.md)). The administrator's browser cannot do it either: 25, 465 and 587 are on the Fetch standard's list of blocked ports. Two substitutes were offered and declined the same day: a Health line "last connection from outside on each port", read from `mail.log`, and a command in the help to run from one's own machine. **Returns if:** the owner accepts an outside probe, or asks for one of the two substitutes. - **preflight as a page of its own** (dropped 2026-10-13): see [health-setup-checks](#health-setup-checks). Its plan, `plans/preflight.md`, was removed and stays in git history. - **csrf-tokens** (decided 2026-10-13) - session-bound tokens on every form. Replaced by [origin-strict](#origin-strict); the conditions for building tokens after all are in [security.md](security.md), ADR § *CSRF via origin checking*. --- ## 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.