design: a domain's report address is one of three choices; row actions are POSTs

Two decisions of the owner, 2026-10-10.

The report address. "Three choices: no reports, the SelfPost address, a
typed address. No reports by default. A button that fills the typed
address with one's own e-mail from the profile." This replaces the
2026-09-21 design in which a domain followed a named user's default: a
co-administrator of a domain was shown another user's login name, and
three explicit choices are simpler.
  - src/out-domain-settings.html: the box is a select of the three
    choices and the address field with a "Use my e-mail" button.
  - src/out-domain.html: the DMARC record's note names the address, with
    no user in it.
  - src/account.html: the DMARC reports box is gone; Account is Profile
    and Password, one box per column.
  - src/settings.html: the lead no longer points at Account for the
    report address.
  - docs/plans/panel-redesign.md: Account e-mail rewritten, the Routes
    row of account without /account/dmarc. docs/schema-migrations.md:
    the columns that go.

Row actions. New password and Delete in a row of the Applications table
are POSTs, so each is a form with a button that looks like the links
beside it - as Sign out became one.
  - src/out-domain.html: Edit / New password / Delete per row, the box
    foot about confirmations is back; the login cell does not wrap.
  - theme.css: .table td.sp-actions form sits inline with the links'
    spacing, and its button takes the link's colour and type.
  - src/components.html shows the pattern once.

check.py: 0 violations. outlines.json: only account changed (two equal
columns, one box each).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-10 12:38:35 +03:00
co-authored by Claude Opus 5.5
parent 4ab63d08ae
commit fc53ad61ee
14 changed files with 89 additions and 139 deletions
+5 -32
View File
@@ -69,24 +69,26 @@
<div class="sp-grow"> <div class="sp-grow">
<p class="sp-kicker">Signed in as admin · global</p> <p class="sp-kicker">Signed in as admin · global</p>
<h1 class="title is-3">Account</h1> <h1 class="title is-3">Account</h1>
<p class="sp-lead">Who you are to this panel and where reports about your domains go. Applications keep their own logins and passwords and are not affected.</p> <p class="sp-lead">Who you are to this panel. Applications keep their own logins and passwords and are not affected.</p>
</div> </div>
</div> </div>
<div class="columns"> <div class="columns">
<div class="column is-5"> <div class="column is-6">
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Profile</h2></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>Profile</h2></div>
<div class="sp-box-body"> <div class="sp-box-body">
<form class="sp-form" method="post" action="#"> <form class="sp-form" method="post" action="#">
<div class="field"><label class="label" for="username">Username</label><div class="control has-icons-left"><input class="input" id="username" name="username" value="admin" autocomplete="username" spellcheck="false" required><span class="icon is-left"><i class="ti ti-user"></i></span></div></div> <div class="field"><label class="label" for="username">Username</label><div class="control has-icons-left"><input class="input" id="username" name="username" value="admin" autocomplete="username" spellcheck="false" required><span class="icon is-left"><i class="ti ti-user"></i></span></div></div>
<div class="field"><label class="label" for="email">E-mail</label><div class="control has-icons-left"><input class="input" id="email" name="email" type="email" value="mix@example.org" autocomplete="email"><span class="icon is-left"><i class="ti ti-mail"></i></span></div> <div class="field"><label class="label" for="email">E-mail</label><div class="control has-icons-left"><input class="input" id="email" name="email" type="email" value="mix@example.org" autocomplete="email"><span class="icon is-left"><i class="ti ti-mail"></i></span></div>
<p class="help">Where the panel writes to you. It can also receive DMARC reports — your choice, under <a href="#dmarc">DMARC reports</a>.</p></div> <p class="help">Your own address. A domain's report address can be filled with it from Domain settings.</p></div>
<button type="submit" class="button is-primary">Save profile</button> <button type="submit" class="button is-primary">Save profile</button>
</form> </form>
</div> </div>
</div> </div>
</div>
<div class="column is-6">
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Password</h2></div> <div class="sp-box-head"><span class="sp-no">02</span><h2>Password</h2></div>
<div class="sp-box-body"> <div class="sp-box-body">
@@ -102,35 +104,6 @@
</div> </div>
</div> </div>
</div> </div>
<div class="column is-7">
<div class="box" id="dmarc">
<div class="sp-box-head"><span class="sp-no">03</span><h2>DMARC reports</h2><div class="sp-end"><span class="tag is-success is-light">authorized</span></div><a class="sp-help" href="help.html#dmarc" title="What the reports are and where they go"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-record">
<form class="sp-form" method="post" action="#">
<div class="field"><label class="label" for="rua">My default report address</label>
<div class="control"><div class="select is-fullwidth"><select id="rua" name="dmarc_default">
<option>SelfPost hosted — read them under DMARC reports</option>
<option selected>My account e-mail — mix@example.org</option>
<option>Another address…</option>
<option>No reports</option>
</select></div></div>
<p class="help">Used by every outbound domain whose report address is set to <em>admin's default</em> — 2 of your 3 now. Changing it changes the DMARC record those domains must publish. The hosted address is parsed by SelfPost; any other just receives the raw XML.</p></div>
<button type="submit" class="button is-primary">Save report address</button>
</form>
</div>
<div class="sp-record">
<div class="sp-record-head"><h3>Report authorization</h3><span class="tag is-success is-light">published</span><small>Needed because the address is on another domain than the senders</small></div>
<div class="sp-row">
<div class="field"><label class="label">Host / name</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="*._report._dmarc.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
</div>
<div class="field"><label class="label">Value to publish</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="v=DMARC1"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
</div>
</div>
</div>
</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> <footer class="sp-foot">SelfPost 1.8.0 · © 2026 Mix · <a href="#">License (AGPL-3.0)</a> · <a href="#">Source</a> · No warranty</footer>
@@ -172,7 +172,7 @@
<div class="table-container"><table class="table is-fullwidth is-hoverable"> <div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Time, UTC</th><th>Name</th><th>sp-clip</th><th>Status</th><th></th></tr></thead> <thead><tr><th>Time, UTC</th><th>Name</th><th>sp-clip</th><th>Status</th><th></th></tr></thead>
<tbody> <tbody>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:01:52</td><td><a href="#"><strong>the row's link</strong></a></td><td class="sp-clip">A long subject is clipped with an ellipsis instead of wrapping the row</td><td><span class="tag is-success is-light">sent</span></td><td class="sp-actions"><a href="#">Details</a><a class="has-text-danger" href="#">Delete</a></td></tr> <tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:01:52</td><td><a href="#"><strong>the row's link</strong></a></td><td class="sp-clip">A long subject is clipped with an ellipsis instead of wrapping the row</td><td><span class="tag is-success is-light">sent</span></td><td class="sp-actions"><a href="#">Details</a><form method="post" action="#"><button type="submit" class="has-text-danger">Delete</button></form></td></tr>
</tbody> </tbody>
</table></div> </table></div>
<div class="sp-box-foot"><span>Page 1 of 9</span><a href="#">Older →</a></div> <div class="sp-box-foot"><span>Page 1 of 9</span><a href="#">Older →</a></div>
@@ -81,13 +81,13 @@
<form class="sp-form" method="post" action="#"> <form class="sp-form" method="post" action="#">
<div class="field"><label class="label" for="rua">Aggregate reports (rua=)</label> <div class="field"><label class="label" for="rua">Aggregate reports (rua=)</label>
<div class="control"><div class="select is-fullwidth"><select id="rua" name="dmarc_rua_mode"> <div class="control"><div class="select is-fullwidth"><select id="rua" name="dmarc_rua_mode">
<option selected>admin's default — mix@example.org</option> <option>No reports</option>
<option>SelfPost hosted (example.org@dmarc.mail.example.org)</option> <option>SelfPost hosted — example.org@dmarc.mail.example.org</option>
<option>My default instead — (you are admin)</option> <option selected>A specific address</option>
<option>No aggregate reports</option>
<option>Custom address</option>
</select></div></div> </select></div></div>
<p class="help">A default belongs to a user and is set under their <a href="account.html#dmarc">Account</a>; a domain follows one named user, so two people sharing a domain never pull it two ways. Changing this changes the DMARC record to publish.</p></div> <p class="help">No reports leaves <code>rua=</code> out of the DMARC record. SelfPost hosted sends them to an address on this server, which parses them for DMARC reports. A specific address is any mailbox, and it receives the raw XML. Changing this changes the DMARC record to publish.</p></div>
<div class="field"><label class="label" for="rua_email">Report address</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" id="rua_email" name="dmarc_rua_email" type="email" value="reports@acme.io" placeholder="reports@your-mail-domain.com" autocomplete="off" spellcheck="false"></div><div class="control"><button type="button" class="button" data-fill="rua_email" data-fill-value="mix@example.org"><i class="ti ti-mail"></i>Use my e-mail</button></div></div></div>
<button type="submit" class="button is-primary">Save report address</button> <button type="submit" class="button is-primary">Save report address</button>
</form> </form>
</div> </div>
@@ -132,14 +132,14 @@
</div> </div>
<div class="sp-record"> <div class="sp-record">
<div class="sp-record-head"><h3>DMARC</h3><span class="tag is-success is-light">published</span><small>Report address: <a href="out-domain-settings.html#reports">admin's default</a></small></div> <div class="sp-record-head"><h3>DMARC</h3><span class="tag is-success is-light">published</span><small>Report address: <a href="out-domain-settings.html#reports">example.org@dmarc.mail.example.org</a></small></div>
<div class="sp-row"> <div class="sp-row">
<div class="field"><label class="label">Host / name</label> <div class="field"><label class="label">Host / name</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="_dmarc.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div> <div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="_dmarc.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div> <div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
</div> </div>
<div class="field"><label class="label">Value to publish</label> <div class="field"><label class="label">Value to publish</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="v=DMARC1; p=none; rua=mailto:dmarc@mail.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div> <div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="v=DMARC1; p=none; rua=mailto:example.org@dmarc.mail.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div>
<p class="help"><code>p=none</code> does not affect delivery. Tighten to <code>quarantine</code>, then <code>reject</code>, once the <a href="dmarc-domain.html">reports</a> look clean.</p></div> <p class="help"><code>p=none</code> does not affect delivery. Tighten to <code>quarantine</code>, then <code>reject</code>, once the <a href="dmarc-domain.html">reports</a> look clean.</p></div>
</div> </div>
</div> </div>
@@ -149,10 +149,10 @@
<div class="table-container"><table class="table is-fullwidth is-hoverable"> <div class="table-container"><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> <thead><tr><th>Login</th><th>May send as</th><th>30 days</th><th>Limits</th><th></th></tr></thead>
<tbody> <tbody>
<tr><td class="sp-mono"><strong>prod-server</strong></td><td>*@example.org</td><td class="sp-muted">1 102 msg · peak 96/h</td><td><span class="tag is-success is-light">200 / h</span></td> <tr><td class="sp-mono sp-nowrap"><strong>prod-server</strong></td><td>*@example.org</td><td class="sp-muted">1 102 msg · peak 96/h</td><td><span class="tag is-success is-light">200 / h</span></td>
<td class="sp-actions"><a href="out-app.html">Edit</a><a href="out-app-created.html">New password</a><a class="has-text-danger" href="#">Delete</a></td></tr> <td class="sp-actions"><a href="out-app.html">Edit</a><form method="post" action="out-app-created.html"><button type="submit">New password</button></form><form method="post" action="out-domain.html"><button type="submit" class="has-text-danger">Delete</button></form></td></tr>
<tr><td class="sp-mono"><strong>alerts</strong></td><td>alerts@example.org, noc@example.org</td><td class="sp-muted">182 msg · peak 14/h</td><td><span class="tag is-light">domain</span> <span class="tag is-success is-light">2 IPs</span></td> <tr><td class="sp-mono sp-nowrap"><strong>alerts</strong></td><td>alerts@example.org, noc@example.org</td><td class="sp-muted">182 msg · peak 14/h</td><td><span class="tag is-light">domain</span> <span class="tag is-success is-light">2 IPs</span></td>
<td class="sp-actions"><a href="out-app.html">Edit</a><a href="out-app-created.html">New password</a><a class="has-text-danger" href="#">Delete</a></td></tr> <td class="sp-actions"><a href="out-app.html">Edit</a><form method="post" action="out-app-created.html"><button type="submit">New password</button></form><form method="post" action="out-domain.html"><button type="submit" class="has-text-danger">Delete</button></form></td></tr>
</tbody> </tbody>
</table></div> </table></div>
<div class="sp-box-foot">New password and Delete ask for confirmation; the old password stops working at once.</div> <div class="sp-box-foot">New password and Delete ask for confirmation; the old password stops working at once.</div>
@@ -4,14 +4,13 @@
{ {
"columns": [ "columns": [
{ {
"is-5": [ "is-6": [
"box[Profile]:body", "box[Profile]:body"
"box[Password]:body"
] ]
}, },
{ {
"is-7": [ "is-6": [
"box[DMARC reports]:record×2" "box[Password]:body"
] ]
} }
] ]
@@ -69,7 +69,7 @@
<div class="sp-grow"> <div class="sp-grow">
<p class="sp-kicker">Server</p> <p class="sp-kicker">Server</p>
<h1 class="title is-3">Settings</h1> <h1 class="title is-3">Settings</h1>
<p class="sp-lead">What holds for the whole instance, whoever is signed in. Your own name, e-mail and DMARC report address are under <a href="account.html">Account</a>.</p> <p class="sp-lead">What holds for the whole instance, whoever is signed in. Your own name, e-mail and password are under <a href="account.html">Account</a>.</p>
</div> </div>
</div> </div>
@@ -3,24 +3,26 @@
<div class="sp-grow"> <div class="sp-grow">
<p class="sp-kicker">Signed in as admin · global</p> <p class="sp-kicker">Signed in as admin · global</p>
<h1 class="title is-3">Account</h1> <h1 class="title is-3">Account</h1>
<p class="sp-lead">Who you are to this panel and where reports about your domains go. Applications keep their own logins and passwords and are not affected.</p> <p class="sp-lead">Who you are to this panel. Applications keep their own logins and passwords and are not affected.</p>
</div> </div>
</div> </div>
<div class="columns"> <div class="columns">
<div class="column is-5"> <div class="column is-6">
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">01</span><h2>Profile</h2></div> <div class="sp-box-head"><span class="sp-no">01</span><h2>Profile</h2></div>
<div class="sp-box-body"> <div class="sp-box-body">
<form class="sp-form" method="post" action="#"> <form class="sp-form" method="post" action="#">
<div class="field"><label class="label" for="username">Username</label><div class="control has-icons-left"><input class="input" id="username" name="username" value="admin" autocomplete="username" spellcheck="false" required><span class="icon is-left"><i class="ti ti-user"></i></span></div></div> <div class="field"><label class="label" for="username">Username</label><div class="control has-icons-left"><input class="input" id="username" name="username" value="admin" autocomplete="username" spellcheck="false" required><span class="icon is-left"><i class="ti ti-user"></i></span></div></div>
<div class="field"><label class="label" for="email">E-mail</label><div class="control has-icons-left"><input class="input" id="email" name="email" type="email" value="mix@example.org" autocomplete="email"><span class="icon is-left"><i class="ti ti-mail"></i></span></div> <div class="field"><label class="label" for="email">E-mail</label><div class="control has-icons-left"><input class="input" id="email" name="email" type="email" value="mix@example.org" autocomplete="email"><span class="icon is-left"><i class="ti ti-mail"></i></span></div>
<p class="help">Where the panel writes to you. It can also receive DMARC reports — your choice, under <a href="#dmarc">DMARC reports</a>.</p></div> <p class="help">Your own address. A domain's report address can be filled with it from Domain settings.</p></div>
<button type="submit" class="button is-primary">Save profile</button> <button type="submit" class="button is-primary">Save profile</button>
</form> </form>
</div> </div>
</div> </div>
</div>
<div class="column is-6">
<div class="box"> <div class="box">
<div class="sp-box-head"><span class="sp-no">02</span><h2>Password</h2></div> <div class="sp-box-head"><span class="sp-no">02</span><h2>Password</h2></div>
<div class="sp-box-body"> <div class="sp-box-body">
@@ -36,33 +38,4 @@
</div> </div>
</div> </div>
</div> </div>
<div class="column is-7">
<div class="box" id="dmarc">
<div class="sp-box-head"><span class="sp-no">03</span><h2>DMARC reports</h2><div class="sp-end"><span class="tag is-success is-light">authorized</span></div><a class="sp-help" href="help.html#dmarc" title="What the reports are and where they go"><i class="ti ti-help-circle"></i></a></div>
<div class="sp-record">
<form class="sp-form" method="post" action="#">
<div class="field"><label class="label" for="rua">My default report address</label>
<div class="control"><div class="select is-fullwidth"><select id="rua" name="dmarc_default">
<option>SelfPost hosted — read them under DMARC reports</option>
<option selected>My account e-mail — mix@example.org</option>
<option>Another address…</option>
<option>No reports</option>
</select></div></div>
<p class="help">Used by every outbound domain whose report address is set to <em>admin's default</em> — 2 of your 3 now. Changing it changes the DMARC record those domains must publish. The hosted address is parsed by SelfPost; any other just receives the raw XML.</p></div>
<button type="submit" class="button is-primary">Save report address</button>
</form>
</div>
<div class="sp-record">
<div class="sp-record-head"><h3>Report authorization</h3><span class="tag is-success is-light">published</span><small>Needed because the address is on another domain than the senders</small></div>
<div class="sp-row">
<div class="field"><label class="label">Host / name</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="*._report._dmarc.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
</div>
<div class="field"><label class="label">Value to publish</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="v=DMARC1"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
</div>
</div>
</div>
</div> </div>
@@ -106,7 +106,7 @@
<div class="table-container"><table class="table is-fullwidth is-hoverable"> <div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Time, UTC</th><th>Name</th><th>sp-clip</th><th>Status</th><th></th></tr></thead> <thead><tr><th>Time, UTC</th><th>Name</th><th>sp-clip</th><th>Status</th><th></th></tr></thead>
<tbody> <tbody>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:01:52</td><td><a href="#"><strong>the row's link</strong></a></td><td class="sp-clip">A long subject is clipped with an ellipsis instead of wrapping the row</td><td><span class="tag is-success is-light">sent</span></td><td class="sp-actions"><a href="#">Details</a><a class="has-text-danger" href="#">Delete</a></td></tr> <tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:01:52</td><td><a href="#"><strong>the row's link</strong></a></td><td class="sp-clip">A long subject is clipped with an ellipsis instead of wrapping the row</td><td><span class="tag is-success is-light">sent</span></td><td class="sp-actions"><a href="#">Details</a><form method="post" action="#"><button type="submit" class="has-text-danger">Delete</button></form></td></tr>
</tbody> </tbody>
</table></div> </table></div>
<div class="sp-box-foot"><span>Page 1 of 9</span><a href="#">Older →</a></div> <div class="sp-box-foot"><span>Page 1 of 9</span><a href="#">Older →</a></div>
@@ -15,13 +15,13 @@
<form class="sp-form" method="post" action="#"> <form class="sp-form" method="post" action="#">
<div class="field"><label class="label" for="rua">Aggregate reports (rua=)</label> <div class="field"><label class="label" for="rua">Aggregate reports (rua=)</label>
<div class="control"><div class="select is-fullwidth"><select id="rua" name="dmarc_rua_mode"> <div class="control"><div class="select is-fullwidth"><select id="rua" name="dmarc_rua_mode">
<option selected>admin's default — mix@example.org</option> <option>No reports</option>
<option>SelfPost hosted (example.org@dmarc.mail.example.org)</option> <option>SelfPost hosted — example.org@dmarc.mail.example.org</option>
<option>My default instead — (you are admin)</option> <option selected>A specific address</option>
<option>No aggregate reports</option>
<option>Custom address</option>
</select></div></div> </select></div></div>
<p class="help">A default belongs to a user and is set under their <a href="account.html#dmarc">Account</a>; a domain follows one named user, so two people sharing a domain never pull it two ways. Changing this changes the DMARC record to publish.</p></div> <p class="help">No reports leaves <code>rua=</code> out of the DMARC record. SelfPost hosted sends them to an address on this server, which parses them for DMARC reports. A specific address is any mailbox, and it receives the raw XML. Changing this changes the DMARC record to publish.</p></div>
<div class="field"><label class="label" for="rua_email">Report address</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" id="rua_email" name="dmarc_rua_email" type="email" value="reports@acme.io" placeholder="reports@your-mail-domain.com" autocomplete="off" spellcheck="false"></div><div class="control"><button type="button" class="button" data-fill="rua_email" data-fill-value="mix@example.org"><i class="ti ti-mail"></i>Use my e-mail</button></div></div></div>
<button type="submit" class="button is-primary">Save report address</button> <button type="submit" class="button is-primary">Save report address</button>
</form> </form>
</div> </div>
@@ -66,14 +66,14 @@
</div> </div>
<div class="sp-record"> <div class="sp-record">
<div class="sp-record-head"><h3>DMARC</h3><span class="tag is-success is-light">published</span><small>Report address: <a href="out-domain-settings.html#reports">admin's default</a></small></div> <div class="sp-record-head"><h3>DMARC</h3><span class="tag is-success is-light">published</span><small>Report address: <a href="out-domain-settings.html#reports">example.org@dmarc.mail.example.org</a></small></div>
<div class="sp-row"> <div class="sp-row">
<div class="field"><label class="label">Host / name</label> <div class="field"><label class="label">Host / name</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="_dmarc.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div> <div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="_dmarc.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div></div>
<div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div> <div class="field"><label class="label">Type</label><div class="control"><input class="input" readonly value="TXT"></div></div>
</div> </div>
<div class="field"><label class="label">Value to publish</label> <div class="field"><label class="label">Value to publish</label>
<div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="v=DMARC1; p=none; rua=mailto:dmarc@mail.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div> <div class="field has-addons"><div class="control is-expanded"><input class="input" readonly value="v=DMARC1; p=none; rua=mailto:example.org@dmarc.mail.example.org"></div><div class="control"><button type="button" class="button"><i class="ti ti-copy"></i>Copy</button></div></div>
<p class="help"><code>p=none</code> does not affect delivery. Tighten to <code>quarantine</code>, then <code>reject</code>, once the <a href="dmarc-domain.html">reports</a> look clean.</p></div> <p class="help"><code>p=none</code> does not affect delivery. Tighten to <code>quarantine</code>, then <code>reject</code>, once the <a href="dmarc-domain.html">reports</a> look clean.</p></div>
</div> </div>
</div> </div>
@@ -83,10 +83,10 @@
<div class="table-container"><table class="table is-fullwidth is-hoverable"> <div class="table-container"><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> <thead><tr><th>Login</th><th>May send as</th><th>30 days</th><th>Limits</th><th></th></tr></thead>
<tbody> <tbody>
<tr><td class="sp-mono"><strong>prod-server</strong></td><td>*@example.org</td><td class="sp-muted">1 102 msg · peak 96/h</td><td><span class="tag is-success is-light">200 / h</span></td> <tr><td class="sp-mono sp-nowrap"><strong>prod-server</strong></td><td>*@example.org</td><td class="sp-muted">1 102 msg · peak 96/h</td><td><span class="tag is-success is-light">200 / h</span></td>
<td class="sp-actions"><a href="out-app.html">Edit</a><a href="out-app-created.html">New password</a><a class="has-text-danger" href="#">Delete</a></td></tr> <td class="sp-actions"><a href="out-app.html">Edit</a><form method="post" action="out-app-created.html"><button type="submit">New password</button></form><form method="post" action="out-domain.html"><button type="submit" class="has-text-danger">Delete</button></form></td></tr>
<tr><td class="sp-mono"><strong>alerts</strong></td><td>alerts@example.org, noc@example.org</td><td class="sp-muted">182 msg · peak 14/h</td><td><span class="tag is-light">domain</span> <span class="tag is-success is-light">2 IPs</span></td> <tr><td class="sp-mono sp-nowrap"><strong>alerts</strong></td><td>alerts@example.org, noc@example.org</td><td class="sp-muted">182 msg · peak 14/h</td><td><span class="tag is-light">domain</span> <span class="tag is-success is-light">2 IPs</span></td>
<td class="sp-actions"><a href="out-app.html">Edit</a><a href="out-app-created.html">New password</a><a class="has-text-danger" href="#">Delete</a></td></tr> <td class="sp-actions"><a href="out-app.html">Edit</a><form method="post" action="out-app-created.html"><button type="submit">New password</button></form><form method="post" action="out-domain.html"><button type="submit" class="has-text-danger">Delete</button></form></td></tr>
</tbody> </tbody>
</table></div> </table></div>
<div class="sp-box-foot">New password and Delete ask for confirmation; the old password stops working at once.</div> <div class="sp-box-foot">New password and Delete ask for confirmation; the old password stops working at once.</div>
@@ -3,7 +3,7 @@
<div class="sp-grow"> <div class="sp-grow">
<p class="sp-kicker">Server</p> <p class="sp-kicker">Server</p>
<h1 class="title is-3">Settings</h1> <h1 class="title is-3">Settings</h1>
<p class="sp-lead">What holds for the whole instance, whoever is signed in. Your own name, e-mail and DMARC report address are under <a href="account.html">Account</a>.</p> <p class="sp-lead">What holds for the whole instance, whoever is signed in. Your own name, e-mail and password are under <a href="account.html">Account</a>.</p>
</div> </div>
</div> </div>
+8 -1
View File
@@ -161,7 +161,14 @@ code { background: var(--sp-well); color: var(--sp-ink); border-radius: 3px; pad
.box > .table-container td:first-child, .box > .table-container th:first-child { padding-left: 1.25rem; } .box > .table-container td:first-child, .box > .table-container th:first-child { padding-left: 1.25rem; }
.box > .table-container td:last-child, .box > .table-container th:last-child { padding-right: 1.25rem; } .box > .table-container td:last-child, .box > .table-container th:last-child { padding-right: 1.25rem; }
.table td.sp-actions { text-align: right; white-space: nowrap; } .table td.sp-actions { text-align: right; white-space: nowrap; }
.table td.sp-actions a { margin-left: .9rem; } /* An action that changes state (New password, Delete) is a POST, so it is a form
with a button, not a link. The form sits inline like the links beside it and
the button is handed the link's colour and type; Bulma already resets the
button and shares the pointer and the focus ring between a and button, and a
link here has no hover colour of its own. */
.table td.sp-actions a, .table td.sp-actions form { margin-left: .9rem; }
.table td.sp-actions form { display: inline; }
.table td.sp-actions button { color: var(--bulma-link-text); font: inherit; }
.table td.sp-clip { max-width: 13rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .table td.sp-clip { max-width: 13rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tag { font-family: var(--sp-mono); font-weight: 600; letter-spacing: .04em; } .tag { font-family: var(--sp-mono); font-weight: 600; letter-spacing: .04em; }
.sp-empty { padding: 2.2rem 1.25rem; text-align: center; color: var(--sp-muted); } .sp-empty { padding: 2.2rem 1.25rem; text-align: center; color: var(--sp-muted); }
+29 -24
View File
@@ -56,7 +56,7 @@ Navigation is grouped by mail direction. Page titles carry the group
| Outbound | Domains · Log · Queue · DMARC reports | `/domains`, `/deliveries`, `/mail-queue`, `/dmarc` | | Outbound | Domains · Log · Queue · DMARC reports | `/domains`, `/deliveries`, `/mail-queue`, `/dmarc` |
| Inbound | Domains · Log · Queue | `/inbound`; Log and Queue are new | | Inbound | Domains · Log · Queue | `/inbound`; Log and Queue are new |
| Server | Health · System log · Backup · Users · Settings | Health is new (the tables that left `/status`) | | Server | Health · System log · Backup · Users · Settings | Health is new (the tables that left `/status`) |
| User menu | Account · Help · Sign out | Account is the user's half of `/settings`: credentials, e-mail, DMARC default | | User menu | Account · Help · Sign out | Account is the user's half of `/settings`: credentials and e-mail |
A hidden page leaves no gap in the menu. The inbound and DMARC feature flags 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 stay as in [web.go](../../internal/web/web.go); who sees what changes — see
@@ -125,28 +125,33 @@ only.
## Account e-mail ## Account e-mail
**Decided 2026-09-21 (owner): a user's e-mail is theirs, not a DMARC field.** **Decided 2026-10-10 (owner): a domain's DMARC report address is one of three
Today the column is `users.dmarc_report_email` and the only place it shows is choices, and nothing else exists.** This replaces the 2026-09-21 design, where
the DMARC card, so it reads as if it existed for that alone. a domain followed a named user's default: a co-administrator of a domain was
shown another user's login name, and three explicit choices are simpler.
- The 2.0 baseline has `users.email`. It is what the panel writes to: event - A domain's aggregate-report address (`rua=`) is exactly one of: **no
notifications ([panel-notifications](../roadmap.md#panel-notifications)) and reports** (the state of a new domain), **the SelfPost hosted address**
a reset link ([password-reset](../roadmap.md#password-reset)). (offered only while hosted reports are enabled on the server), or **an
- **Account** edits it; the global administrator sees and sets it on the user address typed for this domain**. There is no per-user default, no domain
form and sees it in the users list. following a user, and no keep / inherit. Changing the choice changes the
- DMARC only *may* use it. **The default report address is a user's setting, DMARC record to publish.
not the server's** (owner, 2026-09-21): it lives on **Account** with its - It is set in **Domain settings** and nowhere else. Beside the typed-address
report-authorization record, as a choice — SelfPost hosted, *my account field there is a button that fills it with the signed-in user's profile
e-mail*, another address, none. Historically it was the administrator's e-mail (`panel.js`, `data-fill`): it only fills, saving is the form's own
profile field, later mirrored into an instance setting only the global user button, and it is absent when the profile has no e-mail. Without scripts the
could edit; Server › Settings keeps nothing of it. field is typed in.
- A domain can have several users, so "the default" must be someone's. A - The 2.0 baseline has `users.email`: the user's own address, edited on
domain's report address is *the default of a named user* (shown as **Account**, set by the global administrator on the user form and shown in
`admin's default — mix@example.org`), the hosted address, a custom one or the users list. It is what the panel writes to — event notifications
none. A new domain follows whoever created it; any user of the domain may ([panel-notifications](../roadmap.md#panel-notifications)) and a reset link
switch it to their own default. If that user is deleted the domain falls ([password-reset](../roadmap.md#password-reset)) — and what the fill button
to *none* and its DMARC check says so. Baseline: `users.dmarc_default_mode`, offers. **Account** is Profile and Password; Server › Settings keeps nothing
`users.dmarc_default_address`, `domains.dmarc_rua_user_id`. of DMARC.
- Baseline: `domains.dmarc_rua` alone (`''` = no reports). The hosted choice is
the address SelfPost derives for the domain and the server's hostname, so it
is stored as that address and read back as hosted when it equals it. Deleting
a user changes no domain's address.
## Routes ## Routes
@@ -177,7 +182,7 @@ list, an action under the thing it changes.
| backup | `GET, POST /server/backup` · `POST /server/backup/import` | `/backup` · `POST /domains/import` | | backup | `GET, POST /server/backup` · `POST /server/backup/import` | `/backup` · `POST /domains/import` |
| users · user · user-delete | `/server/users` · `…/new` · `…/{uid}` · `…/{uid}/delete` | `/users/…` | | users · user · user-delete | `/server/users` · `…/new` · `…/{uid}` · `…/{uid}/delete` | `/users/…` |
| settings | `GET, POST /server/settings` | the instance half of `/settings`: log retention, rate limits | | settings | `GET, POST /server/settings` | the instance half of `/settings`: log retention, rate limits |
| account | `GET /account` · `POST /account/profile`, `/password`, `/dmarc` | `/settings`: credentials and the DMARC default (today `/account` redirects there) | | account | `GET /account` · `POST /account/profile`, `/password` | `/settings`: credentials and the DMARC default (today `/account` redirects there) |
| help · components | `GET /help` · `GET /server/components` | `/help` · — | | help · components | `GET /help` · `GET /server/components` | `/help` · — |
Unchanged: `/healthz`, `/license`, `/static/`, `/setup/{token}`, `/login`, Unchanged: `/healthz`, `/license`, `/static/`, `/setup/{token}`, `/login`,
@@ -505,7 +510,7 @@ only when both are done. Every stage-2 step ends with the evidence of
**Stage 1 — data and routes** (no template work; parallel with stage 0) **Stage 1 — data and routes** (no template work; parallel with stage 0)
- [x] Schema from zero: single `0001_init.sql` baseline with `users.email`, the DMARC default fields, `user_inbound_domains`, the two `all_*` flags; store tests; `schema-migrations.md` rewritten — **Opus** - [x] Schema from zero: single `0001_init.sql` baseline with `users.email`, `user_inbound_domains`, the two `all_*` flags; store tests; `schema-migrations.md` rewritten — **Opus**
- [x] Inbound delegation: `requireInboundDomain`, lists and log filtered in the query, two-list user form data, per-route 404 tests for both roles including another tenant's id — **Opus** - [x] Inbound delegation: `requireInboundDomain`, lists and log filtered in the query, two-list user form data, per-route 404 tests for both roles including another tenant's id — **Opus**
- [x] Routes renamed per § Routes: new paths, one fragment name, old paths removed, subtree `requireGlobal()` for `/server/`; links in the old templates retargeted so the panel works on every commit — **Opus** - [x] Routes renamed per § Routes: new paths, one fragment name, old paths removed, subtree `requireGlobal()` for `/server/`; links in the old templates retargeted so the panel works on every commit — **Opus**
- [x] The application form as one POST; `out-app-created` as the response with `Cache-Control: no-store` — **Opus** - [x] The application form as one POST; `out-app-created` as the response with `Cache-Control: no-store` — **Opus**
+10 -17
View File
@@ -83,9 +83,9 @@ for operators upgrading from older 2.x images.
| Table | Role | | Table | Role |
|-------|------| |-------|------|
| `settings` | Key/value settings of the instance — what is true whoever is signed in (send-log retention). Nothing that belongs to a user | | `settings` | Key/value settings of the instance — what is true whoever is signed in (send-log retention). Nothing that belongs to a user |
| `users` | Panel logins. `role` is `global` or `domain`; `email` is the user's own address; `dmarc_default_mode` / `dmarc_default_address` are their default DMARC report address; `all_domains` / `all_inbound_domains` widen a `domain` user to every domain of that direction | | `users` | Panel logins. `role` is `global` or `domain`; `email` is the user's own address; `all_domains` / `all_inbound_domains` widen a `domain` user to every domain of that direction |
| `sessions` | Panel login sessions (token hash, owning user by id and deleted with them, sliding idle expiry) | | `sessions` | Panel login sessions (token hash, owning user by id and deleted with them, sliding idle expiry) |
| `domains` | Sending (outbound) domains. `dmarc_rua` / `dmarc_rua_user_id` say where aggregate reports go | | `domains` | Sending (outbound) domains. `dmarc_rua` is where aggregate reports go (`''` = no reports) |
| `user_domains` | Outbound domains assigned to a `domain` user | | `user_domains` | Outbound domains assigned to a `domain` user |
| `applications` | SASL applications per domain, with the client-IP restriction (`auth_ip_restrict`, `auth_allowed_ips`) | | `applications` | SASL applications per domain, with the client-IP restriction (`auth_ip_restrict`, `auth_allowed_ips`) |
| `application_addresses` | Explicit From addresses (`list` mode) | | `application_addresses` | Explicit From addresses (`list` mode) |
@@ -108,27 +108,20 @@ and index of this list.
|---|---|---| |---|---|---|
| `admin` created in 0001, dropped in 0005 | never exists | the reason for the squash | | `admin` created in 0001, dropped in 0005 | never exists | the reason for the squash |
| `users.role` = `global` / `domain_admin` | `global` / `domain` | the role is a reach, not a rank — everyone in the panel is an administrator | | `users.role` = `global` / `domain_admin` | `global` / `domain` | the role is a reach, not a rank — everyone in the panel is an administrator |
| `users.dmarc_report_email` | `users.email` | a user's e-mail is theirs; it is what the panel writes to, and DMARC only *may* use it | | `users.dmarc_report_email` | `users.email` | a user's e-mail is theirs; it is what the panel writes to, and a domain's report address can be filled with it |
| instance setting `dmarc_report_email` (mirror of the global user's field) | `users.dmarc_default_mode`, `users.dmarc_default_address` | the default report address is a user's setting, not the server's | | instance setting `dmarc_report_email` (mirror of the global user's field); `domains.dmarc_rua` NULL = inherit the instance default | `domains.dmarc_rua` alone: the address, `''` = no reports | a domain's report address is one of three explicit choices — no reports, hosted, a typed address; nothing is inherited and no user's default exists (2026-10-10) |
| `domains.dmarc_rua` NULL = inherit the instance default, `''` = none | `domains.dmarc_rua_user_id` set = follows that user's default; otherwise `dmarc_rua` is the address, `''` = none | a domain can have several users, so "the default" has to be someone's |
| — | `user_inbound_domains` | inbound is delegated per user, separately from outbound | | — | `user_inbound_domains` | inbound is delegated per user, separately from outbound |
| — | `users.all_domains`, `users.all_inbound_domains` | "All" includes domains added later | | — | `users.all_domains`, `users.all_inbound_domains` | "All" includes domains added later |
| `rate_limits.allowed_ips` (unused since 1.9.0) | gone | client IPs belong to the application (`auth_allowed_ips`) | | `rate_limits.allowed_ips` (unused since 1.9.0) | gone | client IPs belong to the application (`auth_allowed_ips`) |
### Where a domain's DMARC reports go ### Where a domain's DMARC reports go
Resolved in one place, `Store.DomainDMARCRua`: `domains.dmarc_rua` is the address itself and the only thing stored; `''` means
no reports, and it is what a new domain has. The choice shown in Domain
1. `domains.dmarc_rua_user_id` is set → the domain follows that user's default settings is derived from it: `''` is no reports, the address SelfPost derives
(`DMARCDefault.Resolve`): `hosted` → SelfPost's own mailbox for the domain for the domain and the server's hostname (`dmarc.HostedReportAddress`, offered
(no reports while ingest is off), `account` → `users.email`, `custom` → while hosted reports are enabled) is hosted, anything else is an address typed
`users.dmarc_default_address`, `none` → no reports. for the domain. Nothing refers to a user: deleting one changes no domain.
2. Otherwise `domains.dmarc_rua` is the address itself; `''` means a
policy-only record.
A new domain follows whoever created it. Deleting the followed user sets
`dmarc_rua_user_id` to NULL (`ON DELETE SET NULL`), which leaves the domain at
"no reports" — never at another user's default.
--- ---