docs: record the owner's decisions on routes, compatibility and inbound roles

panel-redesign.md gains four sections. Routes: paths follow the new names
and structure (/overview, /outbound/..., /inbound/..., /server/...,
/account), with one <page>/fragment name, the application form as a
single POST and TestRoutesFollowNavigation. No compatibility: the redesign
is 2.0.0, old paths are removed without 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); schema-squash ships as a step of
this plan without its upgrade gate. Who sees what: a domain administrator
manages assigned inbound domains, granted separately from outbound
(user_inbound_domains, per-domain authorization under /inbound/). Room for
roadmap items: where antispam lists, quarantine and preflight land, and
that a feature's screens are drawn mockup-first in this system.

Mockups: the Reload configuration box the first pass missed (now on
Server > Health), the flash component and the sp-help link that replaces
the help drawer, and the user form with two assignment lists. The
antispam plan no longer points its mockup at the superseded panel-ui
folder. Checklist 3/21.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
mix
2026-09-21 22:02:12 +03:00
co-authored by Claude Fable 5.1
parent 1bcfca1586
commit 5e0adb2de5
29 changed files with 293 additions and 78 deletions
@@ -93,7 +93,7 @@
<div class="sp-box-head"><span class="sp-no"><i class="ti ti-info-circle"></i></span><h2>What happens</h2></div>
<div class="sp-box-body">
<p class="mb-2">Changing the password signs out every other session of this account. This one stays signed in.</p>
<p class="sp-muted">A domain administrator sees this same page, and nothing under Server.</p>
<p class="sp-muted">A domain administrator sees this same page, the Outbound and Inbound domains assigned to them, and nothing under Server.</p>
</div>
</div>
</div>
+3 -2
View File
@@ -25,7 +25,7 @@ VOID = {"input", "img", "br", "hr", "meta", "link"}
BULMA = set("""
box button buttons checkbox column columns control field file file-cta file-input file-label
file-name has-addons has-icons-left has-name has-text-danger help icon input label menu menu-label
menu-list progress select table tag textarea title
menu-list notification progress select table tag textarea title
is-3 is-4 is-5 is-6 is-7 is-9 is-active is-danger is-expanded is-fullwidth is-gapless is-half
is-hoverable is-left is-light is-primary is-size-7 is-small is-success is-text is-warning
mb-1 mb-2 mb-3 mb-4 ml-2 mt-4 mt-5
@@ -197,7 +197,8 @@ def main():
if not box.kids or not box.kids[0].has("sp-box-head"):
errors.append(f"{name}: a box must start with box_head")
for k in top[1:]:
ok = k.has("box") or k.has("columns") or (k.tag == "form" and k.has("sp-form"))
# A flash is conditional, so it is allowed at the top level but never part of an outline.
ok = k.has("box") or k.has("columns") or k.has("notification") or (k.tag == "form" and k.has("sp-form"))
if not ok:
errors.append(f"{name}: top-level <{k.tag} class=\"{' '.join(k.classes)}\"> — a page is boxes, columns or one form")
if name != "components":
@@ -72,6 +72,9 @@
<div class="buttons"><a class="button" href="../index.html"><i class="ti ti-arrow-left"></i>Cover sheet</a></div>
</div>
<div class="notification is-success is-light"><i class="ti ti-circle-check"></i><strong>flash</strong> — the result of the last action, between page_head and the first box. Success, or…</div>
<div class="notification is-danger is-light"><i class="ti ti-alert-circle"></i>…<strong>flash, error</strong> — a failure that belongs to the page, not to one field. A field's error stays under the field in Bulma's <code>help is-danger</code>.</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">00</span><h2>Page layouts</h2><div class="sp-end">A page is exactly one of these</div></div>
<div class="sp-box-body">
@@ -115,8 +118,8 @@
<div class="column is-6">
<div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>box · box_head</h2><div class="sp-end">The end slot: a note, a tag, a link or a filter form</div></div>
<div class="sp-box-body"><p>box_body — padded content. A <code>table</code> or a run of <code>sp-record</code> blocks sits directly in the box, without box_body.</p></div>
<div class="sp-box-head"><span class="sp-no">02</span><h2>box · box_head</h2><div class="sp-end">The end slot: a note, a tag, a link or a filter form</div><a class="sp-help" href="help.html#checks" title="Optional: the section's Help topic"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body"><p class="mb-2">The <i class="ti ti-help-circle"></i> after the end slot is <code>sp-help</code>: an optional link to the section's topic on the Help page. It replaces today's help drawer.</p><p>box_body — padded content. A <code>table</code> or a run of <code>sp-record</code> blocks sits directly in the box, without box_body.</p></div>
<div class="sp-box-foot"><span>box_foot — a legend, paging</span><span class="sp-end">end slot</span></div>
</div>
<div class="box sp-danger-zone">
@@ -141,6 +141,14 @@
</div>
</div>
<div class="box" id="configuration">
<div class="sp-box-head"><span class="sp-no">06</span><h2>Configuration</h2><div class="sp-end">Safe to run at any time</div></div>
<div class="sp-box-body">
<p class="mb-3">Regenerates the OpenDKIM and Postfix configuration from the database and reloads both daemons. Use it after editing the files by hand, after restoring a backup, or when the running configuration looks out of step with the domain and application lists. It does not touch the queue or the TLS certificate.</p>
<form method="post" action="#"><button type="submit" class="button"><i class="ti ti-reload"></i>Reload configuration</button></form>
</div>
</div>
<footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
</div>
</main>
@@ -96,7 +96,7 @@
<div class="column is-9">
<div class="box" id="dns">
<div class="sp-box-head"><span class="sp-no">01</span><h2>MX record</h2><div class="sp-end">Checked 1 min ago</div></div>
<div class="sp-box-head"><span class="sp-no">01</span><h2>MX record</h2><div class="sp-end">Checked 1 min ago</div><a class="sp-help" href="help.html#inbound" title="Backup-MX and forwarding"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-record">
<div class="sp-record-head"><h3>MX</h3><span class="tag is-success is-light">published</span><small>Keep any existing primary MX if this is a backup-MX</small></div>
<div class="sp-row">
@@ -78,7 +78,7 @@
<div class="control is-expanded has-icons-left"><input class="input" name="name" type="text" placeholder="lists.example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required><span class="icon is-left"><i class="ti ti-world"></i></span></div>
<div class="control"><button type="submit" class="button is-primary"><i class="ti ti-plus"></i>Add domain</button></div>
</div>
<p class="help">Mail is not accepted until an upstream host is saved on the domain's page.</p>
<p class="help">Mail is not accepted until an upstream host is saved on the domain's page. Adding and deleting domains is for a global administrator; a domain administrator sees the list without this form and without Delete.</p>
</form>
</div>
</div>
@@ -108,7 +108,7 @@
<div class="column is-6">
<div class="box" id="limit">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Rate limit</h2><div class="sp-end"><span class="tag is-success is-light">active</span></div></div>
<div class="sp-box-head"><span class="sp-no">02</span><h2>Rate limit</h2><div class="sp-end"><span class="tag is-success is-light">active</span></div><a class="sp-help" href="help.html#limits" title="Levels 1 and 2"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body">
<form class="sp-form" method="post" action="#">
<div class="field"><label class="label" for="mode">Limit mode</label>
@@ -101,7 +101,7 @@
<div class="column is-9">
<div class="box" id="dns">
<div class="sp-box-head"><span class="sp-no">01</span><h2>DNS records</h2><div class="sp-end">Checked 3 min ago</div></div>
<div class="sp-box-head"><span class="sp-no">01</span><h2>DNS records</h2><div class="sp-end">Checked 3 min ago</div><a class="sp-help" href="help.html#dns" title="What to publish and why"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-record">
<div class="sp-record-head"><h3>DKIM</h3><span class="tag is-success is-light">published</span><small>Not a secret · selector <code>sp2026</code></small></div>
@@ -142,7 +142,7 @@
</div>
<div class="box" id="apps">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Applications</h2><div class="sp-end">SASL logins that may send as this domain</div></div>
<div class="sp-box-head"><span class="sp-no">02</span><h2>Applications</h2><div class="sp-end">SASL logins that may send as this domain</div><a class="sp-help" href="help.html#apps" title="What an application is"><i class="ti ti-help-circle"></i></a></div>
<table class="table is-fullwidth is-hoverable">
<thead><tr><th>Login</th><th>May send as</th><th>30 days</th><th>Limits</th><th></th></tr></thead>
<tbody>
@@ -107,7 +107,8 @@
]
}
]
}
},
"box[Configuration]:body"
],
"help": [
"head",
@@ -385,7 +386,8 @@
},
{
"is-6": [
"box[Assigned domains]:body"
"box[Outbound domains]:body",
"box[Inbound domains]:body"
]
}
]
@@ -73,7 +73,7 @@
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Server health</h2><div class="sp-end"><a href="health.html">All details</a></div></div>
<div class="sp-box-head"><span class="sp-no">01</span><h2>Server health</h2><div class="sp-end"><a href="health.html">All details</a></div><a class="sp-help" href="help.html#checks" title="What these checks mean"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-health">
<a href="health.html#machine"><span class="sp-h-icon"><i class="ti ti-cpu"></i></span><span><span class="sp-h-name">Machine</span><br><span class="sp-h-value">CPU 12 % · RAM 41 %</span><br><span class="sp-h-sub">↓ 18 kB/s · ↑ 44 kB/s</span></span></a>
<a href="health.html#processes"><span class="sp-h-icon"><i class="ti ti-server-cog"></i></span><span><span class="sp-h-name">Processes</span><br><span class="sp-h-value">5 of 5 running</span><br><span class="sp-h-sub">up 18 days</span></span></a>
@@ -30,7 +30,7 @@
<div class="sp-box-head"><span class="sp-no"><i class="ti ti-info-circle"></i></span><h2>What happens</h2></div>
<div class="sp-box-body">
<p class="mb-2">Changing the password signs out every other session of this account. This one stays signed in.</p>
<p class="sp-muted">A domain administrator sees this same page, and nothing under Server.</p>
<p class="sp-muted">A domain administrator sees this same page, the Outbound and Inbound domains assigned to them, and nothing under Server.</p>
</div>
</div>
</div>
@@ -9,6 +9,9 @@
<div class="buttons"><a class="button" href="../index.html"><i class="ti ti-arrow-left"></i>Cover sheet</a></div>
</div>
<div class="notification is-success is-light"><i class="ti ti-circle-check"></i><strong>flash</strong> — the result of the last action, between page_head and the first box. Success, or…</div>
<div class="notification is-danger is-light"><i class="ti ti-alert-circle"></i>…<strong>flash, error</strong> — a failure that belongs to the page, not to one field. A field's error stays under the field in Bulma's <code>help is-danger</code>.</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">00</span><h2>Page layouts</h2><div class="sp-end">A page is exactly one of these</div></div>
<div class="sp-box-body">
@@ -52,8 +55,8 @@
<div class="column is-6">
<div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>box · box_head</h2><div class="sp-end">The end slot: a note, a tag, a link or a filter form</div></div>
<div class="sp-box-body"><p>box_body — padded content. A <code>table</code> or a run of <code>sp-record</code> blocks sits directly in the box, without box_body.</p></div>
<div class="sp-box-head"><span class="sp-no">02</span><h2>box · box_head</h2><div class="sp-end">The end slot: a note, a tag, a link or a filter form</div><a class="sp-help" href="help.html#checks" title="Optional: the section's Help topic"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body"><p class="mb-2">The <i class="ti ti-help-circle"></i> after the end slot is <code>sp-help</code>: an optional link to the section's topic on the Help page. It replaces today's help drawer.</p><p>box_body — padded content. A <code>table</code> or a run of <code>sp-record</code> blocks sits directly in the box, without box_body.</p></div>
<div class="sp-box-foot"><span>box_foot — a legend, paging</span><span class="sp-end">end slot</span></div>
</div>
<div class="box sp-danger-zone">
@@ -77,3 +77,11 @@
</div>
</div>
</div>
<div class="box" id="configuration">
<div class="sp-box-head"><span class="sp-no">06</span><h2>Configuration</h2><div class="sp-end">Safe to run at any time</div></div>
<div class="sp-box-body">
<p class="mb-3">Regenerates the OpenDKIM and Postfix configuration from the database and reloads both daemons. Use it after editing the files by hand, after restoring a backup, or when the running configuration looks out of step with the domain and application lists. It does not touch the queue or the TLS certificate.</p>
<form method="post" action="#"><button type="submit" class="button"><i class="ti ti-reload"></i>Reload configuration</button></form>
</div>
</div>
@@ -33,7 +33,7 @@
<div class="column is-9">
<div class="box" id="dns">
<div class="sp-box-head"><span class="sp-no">01</span><h2>MX record</h2><div class="sp-end">Checked 1 min ago</div></div>
<div class="sp-box-head"><span class="sp-no">01</span><h2>MX record</h2><div class="sp-end">Checked 1 min ago</div><a class="sp-help" href="help.html#inbound" title="Backup-MX and forwarding"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-record">
<div class="sp-record-head"><h3>MX</h3><span class="tag is-success is-light">published</span><small>Keep any existing primary MX if this is a backup-MX</small></div>
<div class="sp-row">
@@ -15,7 +15,7 @@
<div class="control is-expanded has-icons-left"><input class="input" name="name" type="text" placeholder="lists.example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required><span class="icon is-left"><i class="ti ti-world"></i></span></div>
<div class="control"><button type="submit" class="button is-primary"><i class="ti ti-plus"></i>Add domain</button></div>
</div>
<p class="help">Mail is not accepted until an upstream host is saved on the domain's page.</p>
<p class="help">Mail is not accepted until an upstream host is saved on the domain's page. Adding and deleting domains is for a global administrator; a domain administrator sees the list without this form and without Delete.</p>
</form>
</div>
</div>
@@ -45,7 +45,7 @@
<div class="column is-6">
<div class="box" id="limit">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Rate limit</h2><div class="sp-end"><span class="tag is-success is-light">active</span></div></div>
<div class="sp-box-head"><span class="sp-no">02</span><h2>Rate limit</h2><div class="sp-end"><span class="tag is-success is-light">active</span></div><a class="sp-help" href="help.html#limits" title="Levels 1 and 2"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-box-body">
<form class="sp-form" method="post" action="#">
<div class="field"><label class="label" for="mode">Limit mode</label>
@@ -38,7 +38,7 @@
<div class="column is-9">
<div class="box" id="dns">
<div class="sp-box-head"><span class="sp-no">01</span><h2>DNS records</h2><div class="sp-end">Checked 3 min ago</div></div>
<div class="sp-box-head"><span class="sp-no">01</span><h2>DNS records</h2><div class="sp-end">Checked 3 min ago</div><a class="sp-help" href="help.html#dns" title="What to publish and why"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-record">
<div class="sp-record-head"><h3>DKIM</h3><span class="tag is-success is-light">published</span><small>Not a secret · selector <code>sp2026</code></small></div>
@@ -79,7 +79,7 @@
</div>
<div class="box" id="apps">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Applications</h2><div class="sp-end">SASL logins that may send as this domain</div></div>
<div class="sp-box-head"><span class="sp-no">02</span><h2>Applications</h2><div class="sp-end">SASL logins that may send as this domain</div><a class="sp-help" href="help.html#apps" title="What an application is"><i class="ti ti-help-circle"></i></a></div>
<table class="table is-fullwidth is-hoverable">
<thead><tr><th>Login</th><th>May send as</th><th>30 days</th><th>Limits</th><th></th></tr></thead>
<tbody>
@@ -10,7 +10,7 @@
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Server health</h2><div class="sp-end"><a href="health.html">All details</a></div></div>
<div class="sp-box-head"><span class="sp-no">01</span><h2>Server health</h2><div class="sp-end"><a href="health.html">All details</a></div><a class="sp-help" href="help.html#checks" title="What these checks mean"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-health">
<a href="health.html#machine"><span class="sp-h-icon"><i class="ti ti-cpu"></i></span><span><span class="sp-h-name">Machine</span><br><span class="sp-h-value">CPU 12 % · RAM 41 %</span><br><span class="sp-h-sub">↓ 18 kB/s · ↑ 44 kB/s</span></span></a>
<a href="health.html#processes"><span class="sp-h-icon"><i class="ti ti-server-cog"></i></span><span><span class="sp-h-name">Processes</span><br><span class="sp-h-value">5 of 5 running</span><br><span class="sp-h-sub">up 18 days</span></span></a>
@@ -1,8 +1,8 @@
<!-- title: Delete shop-team | section: server | page: users -->
<!-- title: Delete acme-ops | section: server | page: users -->
<div class="sp-head">
<div class="sp-grow">
<p class="sp-kicker"><a href="users.html">Users</a> / <a href="user.html">shop-team</a> /</p>
<h1 class="title is-3">Delete shop-team</h1>
<p class="sp-kicker"><a href="users.html">Users</a> / <a href="user.html">acme-ops</a> /</p>
<h1 class="title is-3">Delete acme-ops</h1>
</div>
</div>
@@ -11,15 +11,15 @@
<div class="box sp-danger-zone">
<div class="sp-box-head"><span class="sp-no"><i class="ti ti-alert-triangle"></i></span><h2>This cannot be undone</h2></div>
<div class="sp-box-body">
<p class="mb-3">Deleting the panel user <strong>shop-team</strong> will:</p>
<p class="mb-3">Deleting the panel user <strong>acme-ops</strong> will:</p>
<ul class="sp-list-check">
<li><i class="ti ti-x"></i>end any signed-in session of this user at once;</li>
<li><i class="ti ti-x"></i>remove the assignment to <strong>shop.example.org</strong>.</li>
<li><i class="ti ti-x"></i>remove the assignments to <strong>notify.acme.io</strong> (outbound) and <strong>acme.io</strong> (inbound).</li>
</ul>
<p class="sp-muted mb-4">The domain, its applications and their passwords are not affected.</p>
<p class="sp-muted mb-4">The domains, their applications and passwords, upstreams and recipients are not affected.</p>
<form method="post" action="users.html">
<div class="buttons">
<button type="submit" class="button is-danger"><i class="ti ti-trash"></i>Delete shop-team</button>
<button type="submit" class="button is-danger"><i class="ti ti-trash"></i>Delete acme-ops</button>
<a class="button is-text" href="user.html">Keep it</a>
</div>
</form>
+15 -7
View File
@@ -1,8 +1,8 @@
<!-- title: shop-team · Users | section: server | page: users -->
<!-- title: acme-ops · Users | section: server | page: users -->
<div class="sp-head">
<div class="sp-grow">
<p class="sp-kicker"><a href="users.html">Users</a> /</p>
<h1 class="title is-3">shop-team</h1>
<h1 class="title is-3">acme-ops</h1>
<p class="sp-lead">The same form, empty, creates a new user.</p>
</div>
<a class="button is-danger is-light" href="user-delete.html"><i class="ti ti-trash"></i>Delete user</a>
@@ -14,22 +14,30 @@
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Sign-in</h2></div>
<div class="sp-box-body">
<div class="field"><label class="label" for="username">Username</label><div class="control"><input class="input" id="username" name="username" value="shop-team" autocomplete="username" spellcheck="false" required></div></div>
<div class="field"><label class="label" for="username">Username</label><div class="control"><input class="input" id="username" name="username" value="acme-ops" autocomplete="username" spellcheck="false" required></div></div>
<div class="field"><label class="label" for="password">Password</label><div class="control"><input class="input" id="password" name="password" type="password" autocomplete="new-password"></div>
<p class="help">Leave empty to keep the current one.</p></div>
<div class="field"><label class="label" for="role">Role</label>
<div class="control"><div class="select is-fullwidth"><select id="role" name="role"><option selected>Domain administrator</option><option>Global administrator</option></select></div></div>
<p class="help">A global administrator sees everything, including Inbound and Server. The only global administrator cannot be demoted or deleted.</p></div>
<p class="help">A global administrator sees everything, including Server, and is the only one who adds or deletes domains. The only global administrator cannot be demoted or deleted.</p></div>
</div>
</div>
</div>
<div class="column is-6">
<div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Assigned domains</h2><div class="sp-end">Required for a domain administrator</div></div>
<div class="sp-box-head"><span class="sp-no">02</span><h2>Outbound domains</h2><div class="sp-end">DNS records, applications, log</div></div>
<div class="sp-box-body">
<div class="field"><label class="checkbox"><input type="checkbox" name="domain_ids"> example.org</label></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="domain_ids" checked> shop.example.org</label></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="domain_ids"> notify.acme.io</label></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="domain_ids"> shop.example.org</label></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="domain_ids" checked> notify.acme.io</label></div>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Inbound domains</h2><div class="sp-end">Upstream, recipients, log</div></div>
<div class="sp-box-body">
<div class="field"><label class="checkbox"><input type="checkbox" name="inbound_domain_ids"> lists.example.org</label></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="inbound_domain_ids" checked> acme.io</label></div>
<p class="help">Granted separately from outbound: the same name on both lists is two assignments. A domain administrator needs at least one domain on either list.</p>
</div>
</div>
</div>
@@ -3,19 +3,21 @@
<div class="sp-grow">
<p class="sp-kicker">Server</p>
<h1 class="title is-3">Users</h1>
<p class="sp-lead">Who may sign in to this panel. A domain administrator sees only the outbound domains assigned to them.</p>
<p class="sp-lead">Who may sign in to this panel. A domain administrator sees only the domains assigned to them — outbound and inbound are granted separately.</p>
</div>
<a class="button is-primary" href="user.html"><i class="ti ti-user-plus"></i>Create user</a>
</div>
<div class="notification is-success is-light"><i class="ti ti-circle-check"></i>User <strong>acme-ops</strong> created.</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Panel users</h2><div class="sp-end">3 users</div></div>
<table class="table is-fullwidth is-hoverable">
<thead><tr><th>Username</th><th>Role</th><th>Domains</th><th></th></tr></thead>
<thead><tr><th>Username</th><th>Role</th><th>Outbound domains</th><th>Inbound domains</th><th></th></tr></thead>
<tbody>
<tr><td><strong>admin</strong> <span class="tag is-light">you</span></td><td><span class="tag is-primary is-light">global</span></td><td class="sp-muted">All</td><td class="sp-actions"><a href="user.html">Edit</a></td></tr>
<tr><td><strong>shop-team</strong></td><td><span class="tag is-light">domain admin</span></td><td class="sp-muted">shop.example.org</td><td class="sp-actions"><a href="user.html">Edit</a></td></tr>
<tr><td><strong>acme-ops</strong></td><td><span class="tag is-light">domain admin</span></td><td class="sp-muted">notify.acme.io</td><td class="sp-actions"><a href="user.html">Edit</a></td></tr>
<tr><td><strong>admin</strong> <span class="tag is-light">you</span></td><td><span class="tag is-primary is-light">global</span></td><td class="sp-muted">All</td><td class="sp-muted">All</td><td class="sp-actions"><a href="user.html">Edit</a></td></tr>
<tr><td><strong>shop-team</strong></td><td><span class="tag is-light">domain admin</span></td><td class="sp-muted">shop.example.org</td><td class="sp-muted">—</td><td class="sp-actions"><a href="user.html">Edit</a></td></tr>
<tr><td><strong>acme-ops</strong></td><td><span class="tag is-light">domain admin</span></td><td class="sp-muted">notify.acme.io</td><td class="sp-muted">acme.io</td><td class="sp-actions"><a href="user.html">Edit</a></td></tr>
</tbody>
</table>
</div>
@@ -87,6 +87,14 @@ code { background: var(--sp-well); color: var(--sp-ink); border-radius: 3px; pad
.sp-box-head .sp-no { font-family: var(--sp-mono); font-size: .75rem; font-weight: 600; color: var(--sp-brick); }
.sp-box-head h2 { font-family: var(--sp-mono); font-size: .8rem; font-weight: 600; letter-spacing: .1em; text-transform: uppercase; margin: 0; }
.sp-box-head .sp-end { margin-left: auto; color: var(--sp-muted); font-size: .85rem; display: flex; gap: .6rem; align-items: center; }
.sp-box-head .sp-help { color: var(--sp-muted); line-height: 1; }
.sp-box-head .sp-help:hover { color: var(--sp-brick); }
.sp-box-head .sp-end + .sp-help { margin-left: 0; }
.sp-box-head h2 + .sp-help { margin-left: auto; }
.notification { border: 1px solid transparent; padding: .8rem 1.25rem; }
.notification.is-success.is-light { border-color: var(--sp-ok-rule); }
.notification.is-danger.is-light { border-color: var(--sp-fail-rule); }
.notification .ti { margin-right: .5rem; }
.box > .sp-box-body { padding: 1.25rem; }
.box > .sp-box-body > :last-child { margin-bottom: 0; }
.box > .table { margin: 0; }
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Delete shop-team · SelfPost</title>
<title>Delete acme-ops · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
@@ -64,8 +64,8 @@
<div class="container sp-page">
<div class="sp-head">
<div class="sp-grow">
<p class="sp-kicker"><a href="users.html">Users</a> / <a href="user.html">shop-team</a> /</p>
<h1 class="title is-3">Delete shop-team</h1>
<p class="sp-kicker"><a href="users.html">Users</a> / <a href="user.html">acme-ops</a> /</p>
<h1 class="title is-3">Delete acme-ops</h1>
</div>
</div>
@@ -74,15 +74,15 @@
<div class="box sp-danger-zone">
<div class="sp-box-head"><span class="sp-no"><i class="ti ti-alert-triangle"></i></span><h2>This cannot be undone</h2></div>
<div class="sp-box-body">
<p class="mb-3">Deleting the panel user <strong>shop-team</strong> will:</p>
<p class="mb-3">Deleting the panel user <strong>acme-ops</strong> will:</p>
<ul class="sp-list-check">
<li><i class="ti ti-x"></i>end any signed-in session of this user at once;</li>
<li><i class="ti ti-x"></i>remove the assignment to <strong>shop.example.org</strong>.</li>
<li><i class="ti ti-x"></i>remove the assignments to <strong>notify.acme.io</strong> (outbound) and <strong>acme.io</strong> (inbound).</li>
</ul>
<p class="sp-muted mb-4">The domain, its applications and their passwords are not affected.</p>
<p class="sp-muted mb-4">The domains, their applications and passwords, upstreams and recipients are not affected.</p>
<form method="post" action="users.html">
<div class="buttons">
<button type="submit" class="button is-danger"><i class="ti ti-trash"></i>Delete shop-team</button>
<button type="submit" class="button is-danger"><i class="ti ti-trash"></i>Delete acme-ops</button>
<a class="button is-text" href="user.html">Keep it</a>
</div>
</form>
+15 -7
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>shop-team · Users · SelfPost</title>
<title>acme-ops · Users · SelfPost</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3/dist/tabler-icons.min.css">
<link rel="stylesheet" href="../shared/brand.css">
@@ -65,7 +65,7 @@
<div class="sp-head">
<div class="sp-grow">
<p class="sp-kicker"><a href="users.html">Users</a> /</p>
<h1 class="title is-3">shop-team</h1>
<h1 class="title is-3">acme-ops</h1>
<p class="sp-lead">The same form, empty, creates a new user.</p>
</div>
<a class="button is-danger is-light" href="user-delete.html"><i class="ti ti-trash"></i>Delete user</a>
@@ -77,22 +77,30 @@
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Sign-in</h2></div>
<div class="sp-box-body">
<div class="field"><label class="label" for="username">Username</label><div class="control"><input class="input" id="username" name="username" value="shop-team" autocomplete="username" spellcheck="false" required></div></div>
<div class="field"><label class="label" for="username">Username</label><div class="control"><input class="input" id="username" name="username" value="acme-ops" autocomplete="username" spellcheck="false" required></div></div>
<div class="field"><label class="label" for="password">Password</label><div class="control"><input class="input" id="password" name="password" type="password" autocomplete="new-password"></div>
<p class="help">Leave empty to keep the current one.</p></div>
<div class="field"><label class="label" for="role">Role</label>
<div class="control"><div class="select is-fullwidth"><select id="role" name="role"><option selected>Domain administrator</option><option>Global administrator</option></select></div></div>
<p class="help">A global administrator sees everything, including Inbound and Server. The only global administrator cannot be demoted or deleted.</p></div>
<p class="help">A global administrator sees everything, including Server, and is the only one who adds or deletes domains. The only global administrator cannot be demoted or deleted.</p></div>
</div>
</div>
</div>
<div class="column is-6">
<div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Assigned domains</h2><div class="sp-end">Required for a domain administrator</div></div>
<div class="sp-box-head"><span class="sp-no">02</span><h2>Outbound domains</h2><div class="sp-end">DNS records, applications, log</div></div>
<div class="sp-box-body">
<div class="field"><label class="checkbox"><input type="checkbox" name="domain_ids"> example.org</label></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="domain_ids" checked> shop.example.org</label></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="domain_ids"> notify.acme.io</label></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="domain_ids"> shop.example.org</label></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="domain_ids" checked> notify.acme.io</label></div>
</div>
</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">03</span><h2>Inbound domains</h2><div class="sp-end">Upstream, recipients, log</div></div>
<div class="sp-box-body">
<div class="field"><label class="checkbox"><input type="checkbox" name="inbound_domain_ids"> lists.example.org</label></div>
<div class="field"><label class="checkbox"><input type="checkbox" name="inbound_domain_ids" checked> acme.io</label></div>
<p class="help">Granted separately from outbound: the same name on both lists is two assignments. A domain administrator needs at least one domain on either list.</p>
</div>
</div>
</div>
+7 -5
View File
@@ -66,19 +66,21 @@
<div class="sp-grow">
<p class="sp-kicker">Server</p>
<h1 class="title is-3">Users</h1>
<p class="sp-lead">Who may sign in to this panel. A domain administrator sees only the outbound domains assigned to them.</p>
<p class="sp-lead">Who may sign in to this panel. A domain administrator sees only the domains assigned to them — outbound and inbound are granted separately.</p>
</div>
<a class="button is-primary" href="user.html"><i class="ti ti-user-plus"></i>Create user</a>
</div>
<div class="notification is-success is-light"><i class="ti ti-circle-check"></i>User <strong>acme-ops</strong> created.</div>
<div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Panel users</h2><div class="sp-end">3 users</div></div>
<table class="table is-fullwidth is-hoverable">
<thead><tr><th>Username</th><th>Role</th><th>Domains</th><th></th></tr></thead>
<thead><tr><th>Username</th><th>Role</th><th>Outbound domains</th><th>Inbound domains</th><th></th></tr></thead>
<tbody>
<tr><td><strong>admin</strong> <span class="tag is-light">you</span></td><td><span class="tag is-primary is-light">global</span></td><td class="sp-muted">All</td><td class="sp-actions"><a href="user.html">Edit</a></td></tr>
<tr><td><strong>shop-team</strong></td><td><span class="tag is-light">domain admin</span></td><td class="sp-muted">shop.example.org</td><td class="sp-actions"><a href="user.html">Edit</a></td></tr>
<tr><td><strong>acme-ops</strong></td><td><span class="tag is-light">domain admin</span></td><td class="sp-muted">notify.acme.io</td><td class="sp-actions"><a href="user.html">Edit</a></td></tr>
<tr><td><strong>admin</strong> <span class="tag is-light">you</span></td><td><span class="tag is-primary is-light">global</span></td><td class="sp-muted">All</td><td class="sp-muted">All</td><td class="sp-actions"><a href="user.html">Edit</a></td></tr>
<tr><td><strong>shop-team</strong></td><td><span class="tag is-light">domain admin</span></td><td class="sp-muted">shop.example.org</td><td class="sp-muted">—</td><td class="sp-actions"><a href="user.html">Edit</a></td></tr>
<tr><td><strong>acme-ops</strong></td><td><span class="tag is-light">domain admin</span></td><td class="sp-muted">notify.acme.io</td><td class="sp-muted">acme.io</td><td class="sp-actions"><a href="user.html">Edit</a></td></tr>
</tbody>
</table>
</div>
+6 -3
View File
@@ -121,9 +121,12 @@ No separate feature flag beyond inbound relay + existing antispam env vars.
### UI
New nav item **Inbound spam** (or subsection under **Inbound**) — journal table
with filters (decision, domain, date range), list management on the same page or
a tab. Mockup: add `docs/assets/panel-ui/inbound_spam.html` in the panel step.
The journal is **Inbound › Log** and list management is a new **Inbound ›
Filter lists** page (global-only: the lists are instance-wide) — see
[panel-redesign.md](panel-redesign.md) § Room for roadmap items. The journal
table has filters (decision, domain, date range). Mockups are drawn first, in
`docs/assets/panel-redesign/panel/src/` under that plan's design contract —
not in the superseded `docs/assets/panel-ui/`.
## Schema (migration `0011_inbound_spam_log.sql`)
+141 -12
View File
@@ -2,7 +2,7 @@
**Status:** candidate
**Date:** 2026-09-21 (replaces the 2026-09-08 givens)
**Version:** `1.x` MINOR once agreed.
**Version:** `2.0.0` MAJOR — a breaking release by the owner's decision (§ No compatibility).
---
@@ -57,10 +57,108 @@ Navigation is grouped by mail direction. Page titles carry the group
| Server | Health · System log · Backup · Users · Settings | Health is new (the tables that left `/status`) |
| User menu | Account · Help · Sign out | Account is the credentials half of `/settings` |
Visibility flags (global vs domain-admin, inbound, DMARC) stay as in
[web.go](../../internal/web/web.go): a hidden page leaves no gap in the menu.
Whether paths are renamed to match (`/outbound/log`, …) with redirects from
the old ones is decided in checklist step 3, not by the implementer.
A hidden page leaves no gap in the menu. The inbound and DMARC feature flags
stay as in [web.go](../../internal/web/web.go); who sees what changes — see
§ Who sees what.
## No compatibility
**Decided 2026-09-21 (owner): nothing is kept compatible across the redesign,
and the migration chain is deleted to zero.** The redesign is `2.0.0`.
- **URLs.** Old paths are not redirected; they are gone. Bookmarks break once.
- **Schema.** `internal/store/migrations/` is emptied and replaced by one
baseline, `0001_init.sql`, equal to the 2.0 schema (including the new
assignment table below). No historical `admin` table, no create-then-drop.
This is [schema-squash](../roadmap.md#schema-squash) with its trigger now
decided and without the upgrade gate that item planned: 2.0 starts from an
empty data directory.
- **Docs.** `schema-migrations.md` shrinks to the 2.0 baseline; the 1.x chain
stays in git history.
The only live installation is the owner's, and it is set up again on 2.0.
Nothing is built to ease that move, and nothing needs to be.
## Who sees what
**Decided 2026-09-21 (owner): a domain administrator manages inbound domains
too, and that is granted separately from outbound.** Today every inbound
handler is `requireGlobal()` and `user_domains` references outbound domains
only.
| | Global administrator | Domain administrator |
|---|---|---|
| Overview, Server | yes | no (404) |
| Outbound: domain, applications, settings, log, DMARC | all domains | assigned outbound domains |
| Inbound: domain (upstream, recipients), log | all domains | assigned inbound domains |
| Add / delete a domain, either direction | yes | no |
| Queues, either direction | yes | no, until the queue is a filterable table |
| Account, Help | yes | yes |
- New table `user_inbound_domains (user_id, inbound_domain_id)`, mirroring
`user_domains`; both live in the 2.0 baseline. The same name on both lists
is two assignments — the two kinds of domain stay separate entities.
- The user form has two lists (mockup `user.html`); a domain administrator
needs at least one assignment on either.
- A group with nothing assigned is absent from that user's menu.
- Authorization helpers come in pairs: `requireDomain(id)` and
`requireInboundDomain(id)`; lists and logs are filtered by assignment in the
query, not in the template.
## Routes
**Decided 2026-09-21 (owner): paths are renamed to follow the new names and
structure.** A URL reads like the menu: `/<group>/<page>`, an entity under its
list, an action under the thing it changes.
| Screen (mockup) | New | Today |
|---|---|---|
| overview | `GET /overview` | `/status` |
| health | `GET /server/health` · `POST …/recheck` · `POST …/reload` | tables of `/status` · `/status/recheck` · `/reload` |
| out-domains | `GET, POST /outbound/domains` | `/domains` |
| out-domain | `GET /outbound/domains/{id}` · `POST …/dns-recheck` | `/domains/{id}` |
| out-domain-settings | `GET /outbound/domains/{id}/settings` · `POST …/settings/reports` · `…/settings/ratelimit` · `…/settings/ratelimit/recalc` · `…/settings/export` | cards of `/domains/{id}` · `…/dmarc` · `…/ratelimit` · `…/ratelimit/recalc` · `…/export` |
| out-domain-delete | `GET, POST /outbound/domains/{id}/delete` | `/domains/{id}/delete` |
| out-app | `GET, POST /outbound/domains/{id}/applications/new` · `GET, POST …/applications/{aid}` · `POST …/{aid}/ratelimit/recalc` · `POST …/{aid}/delete` | `POST /domains/{id}/applications` · `POST /applications/{aid}/mode`, `/authips`, `/ratelimit` · `…/recalc` · `…/delete` |
| out-app-created | the response of `POST …/applications/new` and `POST …/{aid}/password`; no GET path, `Cache-Control: no-store` | a card on `/domains/{id}` after the POST |
| out-log · out-message | `GET /outbound/log` · `GET /outbound/log/{id}` | `/deliveries` · `/deliveries/{id}` |
| out-queue | `GET /outbound/queue` | `/mail-queue` |
| dmarc · dmarc-domain · dmarc-report | `GET /outbound/dmarc` · `…/dmarc/domains/{id}` · `…/dmarc/reports/{id}` | `/dmarc/…` |
| in-domains · in-domain | `GET, POST /inbound/domains` · `GET /inbound/domains/{id}` · `POST …/dns-recheck`, `…/upstream`, `…/recipients` | `/inbound` · `/inbound/{id}/…` |
| in-domain-delete | `GET, POST /inbound/domains/{id}/delete` | `/inbound/{id}/delete` |
| in-log · in-queue | `GET /inbound/log` · `GET /inbound/queue` | — (new, see below) |
| system-log | `GET /server/log` | `/system-log` |
| backup | `GET, POST /server/backup` · `POST /server/backup/import` | `/backup` · `POST /domains/import` |
| users · user · user-delete | `/server/users` · `…/new` · `…/{uid}` · `…/{uid}/delete` | `/users/…` |
| settings | `GET, POST /server/settings` | the instance half of `/settings` |
| account | `GET, POST /account` | the credentials half of `/settings` (today `/account` redirects there) |
| help · components | `GET /help` · `GET /server/components` | `/help` · — |
Unchanged: `/healthz`, `/license`, `/static/`, `/setup/{token}`, `/login`,
`/logout`. `GET /` goes to `/overview`, or to `/outbound/domains` for a domain
administrator.
Rules that come with the rename:
- **One fragment name.** Every polled page answers `<page>/fragment`
(`/overview/fragment`, `/outbound/log/fragment`, …) — today it is
`fragment`, `rows` and `body`.
- **The application form is one POST.** The mockup has a single *Save
application*; sender, IP allow-list and rate limit are validated and saved
together, or not at all. The three separate POSTs go away.
- **No redirects.** Old paths, GET and POST alike, are removed (§ No
compatibility), including today's `/account` → `/settings` redirect. The
full list goes under *Removed* in the CHANGELOG.
- **Authorization can follow the tree — for `/server/` only.** Everything
under `/server/` is global-only, so `requireGlobal()` can guard that subtree
once instead of once per handler. `/outbound/` and `/inbound/` are checked
per domain (§ Who sees what). Both are security-relevant: they go through
the security review, and a per-route test asserts 404 for a domain
administrator on every path they must not reach — including another
tenant's domain id.
- `TestRoutesFollowNavigation` — every path in this table is registered with
the listed methods, every menu entry points at a registered path, every old
path answers 404, and no template contains an old path.
**Needs its own roadmap decision before it is built** (the mockups show the
target; none of this is smuggled in with the restyle):
@@ -68,11 +166,36 @@ target; none of this is smuggled in with the restyle):
- Queue as a table — parse `postqueue -j` instead of printing `postqueue -p`.
- Inbound queue — the same queue filtered by recipient domain.
- Inbound log — rides on `inbound-antispam-panel` (`1.10.0`).
- Server › Health — a route for the tables removed from Overview.
- Server › Health — a route for the tables removed from Overview, and the
home of *Reload configuration* (today a card on `/status`).
Until a row above is agreed, its page ships in the old content inside the new
shell, and its menu entry is hidden if the page does not exist.
## Room for roadmap items
The mockups draw today's features plus the four rows above. The other open
roadmap items are **not drawn yet**; each has a place, and none needs a new
component or layout. A feature's screens are drawn mockup-first in this system
(`panel/src/`, `build.py`, `check.py --write`) as the first step of that
feature's own plan — not invented during its implementation.
| Roadmap item | Where it lands | Built from | Drawn |
|---|---|---|---|
| inbound-antispam-panel (agreed, `1.10.0`) | journal = **Inbound › Log** (add client IP and engine to the mockup's columns; `quarantine` joins the decisions); lists = new **Inbound › Filter lists**, global-only because the lists are instance-wide | list layout: add form + table, `status_tag`, filter form in `box_head` | log: yes, to be adjusted · lists: **no** |
| inbound-quarantine (candidate) | new **Inbound › Quarantine**, list + detail with *Release* / *Discard* | list layout; detail like `out-message` with `facts`, `log_pane`, a `sp-danger-zone` box for Discard | **no** |
| preflight (candidate) | new **Server › Preflight**, next to Health: Health is what runs now, Preflight is the deeper on-demand installation check with a test e-mail form | `postmark` verdict, a table of checks with `status_tag`, `facts`, one form box | **no** |
| csrf-tokens | no screen; every form gains a hidden field — forms are rendered through one helper so the token cannot be forgotten | — | n/a |
| template-data-typing | no screen; the component partials are the natural first users of typed data | — | n/a |
| structured-logging, contributing, review follow-ups | none | — | n/a |
The sibling strip under the navbar holds five or six entries per group without
wrapping; Inbound would reach five (Domains · Log · Queue · Quarantine ·
Filter lists), Server six. Past that a group is split rather than squeezed.
Ordering with 2.0: inbound-antispam-panel ships first in `1.10.0` and adds its
migration to the 1.x chain as planned; the 2.0 baseline then folds it in.
## Three layers
1. **Bulma, as shipped.** Vendored `bulma.min.css`, never edited. Only the
@@ -88,7 +211,8 @@ shell, and its menu entry is hidden if the page does not exist.
|---|---|
| `page_head` | path, `<h1>`, lead, actions; optionally a `postmark` |
| `postmark` | the round verdict stamp: ok / `sp-warn` / `sp-fail` |
| `box`, `box_head`, `box_foot` | numbered mono head with an end slot; variants `sp-danger-zone`, `sp-credential` |
| `box`, `box_head`, `box_foot` | numbered mono head with an end slot and an optional `sp-help` link to the section's Help topic (replaces the help drawer); variants `sp-danger-zone`, `sp-credential` |
| `flash` | Bulma `notification is-success / is-danger is-light` between `page_head` and the first box; a field's error stays in `help is-danger` |
| `health_card` | Overview only; ok / `sp-warn` / `sp-fail`; links to its detail |
| `dns_record` | one block per record: status, what to publish, "in DNS now" only on a mismatch |
| `facts` | label / value grid, `sp-big` for headline numbers |
@@ -179,21 +303,26 @@ reviewed.
- Weakening CSP; a JS bundler; a front-end framework.
## Implementation checklist
No code until the roadmap status is **agreed**.
- [x] Owner decision recorded here: keep today's paths or rename with redirects — **owner** (2026-09-21: rename, § Routes)
- [x] Owner decision: no compatibility, migrations to zero, `2.0.0` — **owner** (2026-09-21, § No compatibility)
- [x] Owner decision: inbound is delegated per user, separately from outbound — **owner** (2026-09-21, § Who sees what)
- [ ] Vendor Bulma 1.0.4 and the used Tabler icons (MIT) under `/static`, NOTICE and development.md rows, pinned checksum — **Haiku**
- [ ] Guard tests from the table above, red against today's templates where expected; `design-first` CI step — **Opus**
- [ ] Owner decision recorded here: keep today's paths or rename with redirects — **owner**
- [ ] Guard tests from the table above and `TestRoutesFollowNavigation`, red against today's panel where expected; `design-first` CI step — **Opus**
- [ ] Schema from zero: single `0001_init.sql` baseline with `user_inbound_domains`, store tests, `schema-migrations.md` rewritten — **Opus**
- [ ] Inbound delegation: `requireInboundDomain`, filtered lists and log, two-list user form, per-route 404 tests for both roles — **Opus**
- [ ] Routes renamed per § Routes: new paths, one fragment name, old paths removed, subtree `requireGlobal()` for `/server/` — **Opus**
- [ ] `panel.css` from `panel/theme.css`; `components.html` partials; `status_tag`; a `/components` page behind the global role rendering every partial — **Sonnet**
- [ ] `layout.html`: navbar, perforated edge, sibling strip, user menu, footer; visibility flags — **Sonnet**
- [ ] Signed-out pages: login, setup — **Sonnet**
- [ ] Overview (verdicts) and Account / Settings split — **Sonnet**
- [ ] Outbound: domains, domain, domain settings, application form, shown-once password, delete — **Sonnet**
- [ ] Outbound: domains, domain, domain settings, application form as one POST, shown-once password, delete — **Sonnet**
- [ ] Outbound: log, message, DMARC hub / domain / report — **Sonnet**
- [ ] Inbound: domains, domain, delete — **Sonnet**
- [ ] Server: system log, backup, users, user form, delete; Help — **Sonnet**
- [ ] Remove the old `panel.css` rules and classes nothing references; vocabulary test green with no allow-list — **Sonnet**
- [ ] Technical review of the whole against the mockups, evidence pairs complete — **Opus**
- [ ] guide.md screenshots and wording for the new navigation — **Sonnet**
- [ ] Security review of the route rename, subtree authorization and inbound delegation — **Fable**
- [ ] guide.md screenshots and wording for the new navigation; architecture.md § Panel HTTP surface and § Persistence; security.md roles; *Removed* in the CHANGELOG with one line that 2.0 starts from an empty data directory — **Sonnet**
- [ ] `go vet`, `go test ./...`, e2e suite — **Haiku**
+14 -3
View File
@@ -36,7 +36,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
| 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-redesign | Panel redesign (accepted mockups, design contract) | candidate | 0/15 | [plans/panel-redesign.md](plans/panel-redesign.md) |
| panel-redesign | Panel redesign (accepted mockups, design contract) | candidate | 3/21 | [plans/panel-redesign.md](plans/panel-redesign.md) |
| schema-squash | Squash SQLite migrations into a 2.x baseline | **2.x** | — | — |
**Recommended order** (not binding): the next feature is
@@ -307,7 +307,11 @@ postmark / numbered heads / perforated edge, icons). Contract and checklist:
**Boundary:** templates, one `panel.css`, shared partials, vendored Bulma and
icons. Navigation regroups by mail direction (Overview · Outbound · Inbound ·
Server). Four things the mockups show need their own decision and are not
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. Four things the mockups show need their own decision and are not
smuggled in with the restyle: queue as a table (`postqueue -j`), inbound
queue, inbound log (rides on inbound-antispam-panel), Server › Health. No CSP
change, no JavaScript widgets, no second framework.
@@ -322,7 +326,7 @@ 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:** `1.x` MINOR when agreed; `candidate` until then.
**Version:** `2.0.0` MAJOR; `candidate` until agreed.
---
@@ -348,6 +352,13 @@ replace the squash upgrade gate.
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.
**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.