panel: refine domain detail layout and drop section index
test / test (push) Has been cancelled

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-12 22:16:03 +03:00
parent 44e79c4120
commit f9e259a66d
6 changed files with 299 additions and 384 deletions
+9 -4
View File
@@ -7,10 +7,15 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
### Changed ### Changed
- panel: the domain detail page is wide with paired cards (DKIM ‖ DNS status; - panel: the domain detail page is wide with paired cards (DKIM ‖ SPF+DMARC;
SPF ‖ DMARC; sending settings ‖ add application; rate limit ‖ export). sending settings ‖ add application; export ‖ danger). DNS status,
Applications and Danger stay full-width; SPF and DMARC are separate cards; Applications and Domain settings are full-width. DNS status is two rows
section blurbs are shorter. (DKIM ‖ SPF, DMARC ‖ report authorization); Domain settings pairs DMARC
report mode with the domain rate limit; application Edit opens mode and
rate limit side by side; the custom rua address field is shown only for
Custom address. The in-nav “On this page” section index is removed (Status
already dropped it; the paired domain layout no longer needs it). Section
blurbs are shorter.
- panel: page URLs, browser titles, and headings are aligned — **Settings** is - panel: page URLs, browser titles, and headings are aligned — **Settings** is
now `/settings` (legacy `/account` redirects with 308); the domains list title now `/settings` (legacy `/account` redirects with 308); the domains list title
is `SelfPost — domains`; Status, Users, and user create/edit titles match their is `SelfPost — domains`; Status, Users, and user create/edit titles match their
+41 -49
View File
@@ -259,9 +259,9 @@ th, td { text-align: left; padding: 0.5rem 0.4rem; border-bottom: 1px solid var(
td.time, td.status { white-space: nowrap; } td.time, td.status { white-space: nowrap; }
/* Column headings are set in the mono face: they are labels for machine data /* Column headings are set in the mono face: they are labels for machine data
rather than prose, and the wider tracking a small monospaced capital wants rather than prose, and the wider tracking a small monospaced capital wants
also holds them apart from the first row of values. Same treatment on the also holds them apart from the first row of values. Same treatment on
other two small all-caps labels in the panel (.sections-title, .fact-label), .fact-label, so a heading of that size reads as one thing wherever it
so a heading of that size reads as one thing wherever it appears. */ appears. */
th { th {
font-family: var(--font-mono); font-size: 0.75rem; font-weight: 500; font-family: var(--font-mono); font-size: 0.75rem; font-weight: 500;
text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted);
@@ -329,23 +329,19 @@ button.danger:hover, a.danger:hover { background: var(--danger-fill-hover); }
.app .actions { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.7rem; } .app .actions { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.7rem; }
/* Mode and rate limit open under the whole row rather than inside it. As /* Mode and rate limit open under the whole row rather than inside it. As
<details> the panel sat where its summary was, so opening one cut the row of <details> the panel sat where its summary was, so opening one cut the row of
four controls in half and pushed the rest below a block of fields — the controls in half and pushed the rest below a block of fields — the buttons
buttons moved every time a panel opened or closed. The panels are therefore moved every time a panel opened or closed. The panel is therefore the last
the last children of the row, and the checkbox that opens each one is child of the row, and the checkbox that opens it is visually hidden earlier
visually hidden earlier in the row with its label drawn as the button. The in the row with its label drawn as the button. The checkbox stays in the tab
checkbox stays in the tab order and keeps its focus ring on the label, so it order and keeps its focus ring on the label, so it works from the keyboard,
works from the keyboard, and being pure CSS it also works with JavaScript and being pure CSS it also works with JavaScript blocked, as <details> did. */
blocked, as <details> did. */
.app .actions > .panel-toggle { .app .actions > .panel-toggle {
position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none; position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none;
} }
.app .actions > .panel { display: none; flex: 1 0 100%; } .app .actions > .panel { display: none; flex: 1 0 100%; }
.app .actions > .t-mode:checked ~ .panel-mode, .app .actions > .t-edit:checked ~ .panel-edit { display: block; }
.app .actions > .t-limit:checked ~ .panel-limit { display: block; } .app .actions > .t-edit:checked ~ .for-edit { background: var(--surface-open-bg); }
.app .actions > .t-mode:checked ~ .for-mode, .app .actions > .t-edit:focus-visible ~ .for-edit { outline: 2px solid var(--accent-fill); outline-offset: 2px; }
.app .actions > .t-limit:checked ~ .for-limit { background: var(--surface-open-bg); }
.app .actions > .t-mode:focus-visible ~ .for-mode,
.app .actions > .t-limit:focus-visible ~ .for-limit { outline: 2px solid var(--accent-fill); outline-offset: 2px; }
.panel form { margin-top: 0.6rem; } .panel form { margin-top: 0.6rem; }
/* A panel's own submit is a form button, not one of the controls in the row /* A panel's own submit is a form button, not one of the controls in the row
above, so it takes back the spacing the compact .actions rule zeroes out: above, so it takes back the spacing the compact .actions rule zeroes out:
@@ -362,6 +358,8 @@ button.danger:hover, a.danger:hover { background: var(--danger-fill-hover); }
} }
.app .actions > .panel .panel-buttons button, .app .actions > .panel .panel-buttons button,
.app .actions > .panel .panel-buttons form { margin-top: 0; } .app .actions > .panel .panel-buttons form { margin-top: 0; }
.app .actions > .panel .check-cols { margin-top: 0.6rem; }
.app .actions > .panel .check-col > form { margin-top: 0; }
.credential { border-color: var(--credential-border); background: var(--credential-bg); } .credential { border-color: var(--credential-border); background: var(--credential-bg); }
/* Panel navigation: rendered once from the layout, so it is present on every /* Panel navigation: rendered once from the layout, so it is present on every
authenticated page without each content template having to include it. */ authenticated page without each content template having to include it. */
@@ -370,11 +368,9 @@ button.danger:hover, a.danger:hover { background: var(--danger-fill-hover); }
738px the panel was wide, so as a bar it had to be split over two rows — and 738px the panel was wide, so as a bar it had to be split over two rows — and
even then it cost the top of every page. Standing it up removes that: the even then it cost the top of every page. Standing it up removes that: the
entries share one left edge to scan down, the session sits at the foot where entries share one left edge to scan down, the session sits at the foot where
it is out of the way, and there is room between them for the current page's it is out of the way. Sticky, so the list stays in view while the page
own sections (.sections below), which is what makes the long pages navigable. scrolls past it. The layout template lists the blocks in the order they are
Sticky, so both lists stay in view while the page scrolls past them. The drawn, so reading and tab order follow the eye without a CSS `order`. */
layout template lists the blocks in the order they are drawn, so reading and
tab order follow the eye without a CSS `order`. */
.nav { .nav {
position: sticky; top: 2rem; align-self: flex-start; position: sticky; top: 2rem; align-self: flex-start;
flex: none; width: 13.5rem; flex: none; width: 13.5rem;
@@ -439,32 +435,7 @@ button.danger:hover, a.danger:hover { background: var(--danger-fill-hover); }
/* The icons draw in the entry's own colour, so the active entry's darker text, /* The icons draw in the entry's own colour, so the active entry's darker text,
a link's blue and Sign out's red all carry through without a rule apiece. */ a link's blue and Sign out's red all carry through without a rule apiece. */
.nav .icon { width: 1rem; height: 1rem; flex: none; } .nav .icon { width: 1rem; height: 1rem; flex: none; }
/* The current page's own sections, listed under the page entries. The domain /* Jumping to a card (in-page links) should not leave it touching the top edge. */
page is long enough that the only way to the card you came for was to scroll
past all the ones you did not. Only pages that long carry an index — it comes
from the page's own "sections" template (see layout.html), so a page with
two cards or a short paired Status layout renders nothing here. */
.sections {
display: flex; flex-direction: column; gap: 0.1rem;
padding-top: 0.75rem; border-top: 1px solid var(--border);
}
.sections-title {
margin: 0 0 0.25rem 0.6rem;
font-family: var(--font-mono);
font-size: 0.7rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.1em;
color: var(--muted);
}
/* Quieter and a step in from the page entries: this is an index of one page,
subordinate to the list of pages above it. */
.nav .sections a {
padding: 0.2rem 0.6rem 0.2rem 0.9rem;
font-size: 0.85rem; color: var(--muted); text-decoration: none;
}
.nav .sections a:hover { color: var(--fg); }
/* panel.js marks the section the page is scrolled to. Without JavaScript
nothing is marked and the list is still a working index. */
.nav .sections a.current { color: var(--fg); font-weight: 600; background: var(--nav-active-bg); }
/* Jumping to a card should not leave it touching the top edge of the window. */
.card[id] { scroll-margin-top: 1rem; } .card[id] { scroll-margin-top: 1rem; }
/* Below the width the two columns need (13.5rem of navigation, 1.75rem of gap /* Below the width the two columns need (13.5rem of navigation, 1.75rem of gap
and the 48rem measure, plus the body's padding — the measure, not the column and the 48rem measure, plus the body's padding — the measure, not the column
@@ -488,11 +459,10 @@ button.danger:hover, a.danger:hover { background: var(--danger-fill-hover); }
.nav .brand img { width: 110px; } .nav .brand img { width: 110px; }
/* Each block keeps its own group of entries together and wraps as one; the /* Each block keeps its own group of entries together and wraps as one; the
rules that separated the blocks vertically become the space between them. */ rules that separated the blocks vertically become the space between them. */
.nav .links, .nav .session, .nav .sections { .nav .links, .nav .session {
flex-direction: row; flex-wrap: wrap; align-items: center; flex-direction: row; flex-wrap: wrap; align-items: center;
gap: 0.2rem 0.6rem; padding-top: 0; border-top: 0; gap: 0.2rem 0.6rem; padding-top: 0; border-top: 0;
} }
.sections-title { margin: 0; }
} }
/* Status badges: one vocabulary (ok/warn/error/unknown) shared by the server /* Status badges: one vocabulary (ok/warn/error/unknown) shared by the server
status page and the per-domain DNS checks, so a colour means the same thing status page and the per-domain DNS checks, so a colour means the same thing
@@ -562,9 +532,31 @@ meter { width: 5rem; height: 0.7rem; vertical-align: middle; margin-right: 0.4re
grid keep their spacing from it instead. */ grid keep their spacing from it instead. */
.split > .card + .card { margin-top: 0; } .split > .card + .card { margin-top: 0; }
.split + .card { margin-top: 1.2rem; } .split + .card { margin-top: 1.2rem; }
.card + .split { margin-top: 1.2rem; }
/* Configuration sits outside the polled fragment; without this it would /* Configuration sits outside the polled fragment; without this it would
touch the last .split inside #status-body (a nested .split is not a sibling). */ touch the last .split inside #status-body (a nested .split is not a sibling). */
#status-body + .card { margin-top: 1.2rem; } #status-body + .card { margin-top: 1.2rem; }
/* Peer checks or paired forms inside one card (domain DNS status: two rows of
two; domain settings; application edit). Same auto-fit idea as .split, so a
wide card gets two columns — not three — and falls to one when narrow. */
.check-cols {
display: grid; grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr));
gap: 1rem 1.2rem; margin-top: 1rem;
}
.check-col { min-width: 0; }
.check-col > label:first-child { margin-top: 0; }
.check-col-title {
margin: 1rem 0 0.35rem; font-size: 1.05rem; font-weight: 600;
}
.card > h2 + .check-col-title { margin-top: 0.55rem; }
/* Two short fields on one row (rate-limit message count ‖ window). Falls to
one column when the parent is too narrow for both. */
.field-pair {
display: grid; grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
gap: 0 1rem; margin-top: 0.45rem;
}
.field-pair > div { min-width: 0; }
.field-pair label { margin-top: 0.45rem; }
/* The subject heads a delivery's page, and it is the one heading in the panel /* The subject heads a delivery's page, and it is the one heading in the panel
whose text we do not control: it may be a hundred characters with nothing to whose text we do not control: it may be a hundred characters with nothing to
break on. It wraps to as many lines as it needs (this is the page's name, not break on. It wraps to as many lines as it needs (this is the page's name, not
+23 -63
View File
@@ -72,6 +72,28 @@
}); });
} }
// --- Custom DMARC rua address shown only for "custom" mode ------------
// Same idea as the address list: the email field only applies when the
// operator picks Custom address. With JavaScript blocked the field stays
// visible and the server still ignores it for inherit/none.
function syncCustomAddressField(select) {
var form = select.closest("form");
var field = form && form.querySelector("[data-custom-address]");
if (!field) {
return;
}
field.hidden = select.value !== select.dataset.customMode;
}
function initCustomAddressFields(root) {
root.querySelectorAll("select[data-custom-mode]").forEach(function (select) {
syncCustomAddressField(select);
select.addEventListener("change", function () {
syncCustomAddressField(select);
});
});
}
// --- Domain pick shown only for domain administrators ------------------ // --- Domain pick shown only for domain administrators ------------------
// Global administrators manage every domain, so the assignment checkboxes // Global administrators manage every domain, so the assignment checkboxes
// are irrelevant for that role. The toggle runs on load too, because the // are irrelevant for that role. The toggle runs on load too, because the
@@ -157,74 +179,12 @@
}); });
} }
// --- Section index follows the page -----------------------------------
// The long pages list their own sections in the navigation column (the
// "sections" template). Marking the one currently in view turns that list
// from an index into a position, which is the whole point of it on a page
// nine cards tall. The links work without any of this; only the highlight
// depends on it.
//
// Each pass looks its targets up by id rather than holding on to elements
// found once: the status page replaces its cards wholesale every five
// seconds (adaptive polling on #status-body), and anything remembered here would be
// measuring boxes that had left the document.
var sectionLinks = [];
function markCurrentSection() {
var current = null;
sectionLinks.forEach(function (link) {
var target = document.getElementById(link.hash.slice(1));
// The section in view is the last one whose top has passed the reading
// line; the links are in document order, so the last match wins.
if (target && target.getBoundingClientRect().top <= 100) {
current = link;
}
});
if (window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2) {
// At the foot of the page there is no scroll left to bring the last
// cards up to the reading line, so without this they could never be
// marked however far down you are — and the last card of the domain
// page is the one that deletes it.
current = sectionLinks[sectionLinks.length - 1];
} else if (!current) {
// Above the first heading nothing has been passed yet, and the page is
// still on its first section.
current = sectionLinks[0];
}
sectionLinks.forEach(function (link) {
link.classList.toggle("current", link === current);
});
}
function initSectionIndex() {
sectionLinks = Array.prototype.slice.call(
document.querySelectorAll(".sections a[href^='#']")
);
if (!sectionLinks.length) {
return;
}
var pending = false;
// Scroll fires far more often than the highlight can change, so the work
// is collapsed onto the next frame.
window.addEventListener("scroll", function () {
if (pending) {
return;
}
pending = true;
window.requestAnimationFrame(function () {
pending = false;
markCurrentSection();
});
}, { passive: true });
markCurrentSection();
}
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
initAddressFields(document); initAddressFields(document);
initCustomAddressFields(document);
initDomainPickFields(document); initDomainPickFields(document);
initEncryptFields(document); initEncryptFields(document);
initImportPasswordField(document); initImportPasswordField(document);
initSectionIndex();
}); });
// --- Adaptive monitoring polling --------------------------------------- // --- Adaptive monitoring polling ---------------------------------------
+192 -166
View File
@@ -28,11 +28,63 @@
</div> </div>
{{end}} {{end}}
{{/* Two rows of two checks (.check-cols). */}}
<div class="card" id="dns-status">
<h2>DNS status <span class="st st-{{.DNS.Overall}}">{{.DNS.Overall}}</span></h2>
<p class="muted">Cached a few minutes — use <em>Re-check</em> after
publishing.</p>
<div class="check-cols">
<div class="check-col">
<label>DKIM <span class="st st-{{.DNS.DKIM.Status}}">{{.DNS.DKIM.Status}}</span></label>
<p class="muted">{{.Record.Name}}</p>
{{if ne .DNS.DKIM.Status "ok"}}
<p class="{{if eq .DNS.DKIM.Status "unknown"}}muted{{else}}error{{end}}">{{.DNS.DKIM.Detail}}</p>
{{end}}
{{if .DNS.DKIM.Records}}<span class="code">{{range .DNS.DKIM.Records}}{{.}}
{{end}}</span>{{end}}
</div>
<div class="check-col">
<label>SPF <span class="st st-{{.DNS.SPF.Status}}">{{.DNS.SPF.Status}}</span></label>
<p class="muted">{{.Domain.Name}}</p>
{{if ne .DNS.SPF.Status "ok"}}
<p class="{{if eq .DNS.SPF.Status "unknown"}}muted{{else}}error{{end}}">{{.DNS.SPF.Detail}}</p>
{{end}}
<p class="muted">Shallow check: literal address only, no <code>include:</code> /
<code>redirect=</code>.</p>
{{if .DNS.SPF.Records}}<span class="code">{{range .DNS.SPF.Records}}{{.}}
{{end}}</span>{{end}}
</div>
<div class="check-col">
<label>DMARC <span class="st st-{{.DNS.DMARC.Status}}">{{.DNS.DMARC.Status}}</span></label>
<p class="{{if eq .DNS.DMARC.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DMARC.Detail}}</p>
{{if .DNS.DMARC.Records}}<span class="code">{{range .DNS.DMARC.Records}}{{.}}
{{end}}</span>{{end}}
</div>
<div class="check-col">
{{if .DNS.DMARCReportAuth.Status}}
<label>Report authorization <span class="st st-{{.DNS.DMARCReportAuth.Status}}">{{.DNS.DMARCReportAuth.Status}}</span></label>
<p class="{{if eq .DNS.DMARCReportAuth.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DMARCReportAuth.Detail}}</p>
{{if .DNS.DMARCReportAuth.Records}}<span class="code">{{range .DNS.DMARCReportAuth.Records}}{{.}}
{{end}}</span>{{end}}
{{else}}
<label>Report authorization</label>
<p class="muted">Not required (no external <code>rua=</code>).</p>
{{end}}
</div>
</div>
<form class="inline" method="post" action="/domains/{{.Domain.ID}}/dns-recheck">
<button type="submit">Re-check</button>
</form>
</div>
<div class="split"> <div class="split">
<div class="card" id="dkim"> <div class="card" id="dkim">
<h2>DKIM DNS record</h2> <h2>DKIM record</h2>
<p class="muted">Publish this TXT for <strong>{{.Domain.Name}}</strong>.
It is not a secret.</p>
<label>Host / name</label> <label>Host / name</label>
<div class="code-row"> <div class="code-row">
@@ -49,91 +101,42 @@
<button type="button" class="copy">Copy</button> <button type="button" class="copy">Copy</button>
</div> </div>
<p class="muted">Signed with selector <strong>{{.Domain.DKIMSelector}}</strong>.</p> <p class="muted">Not a secret. Signed with selector
<strong>{{.Domain.DKIMSelector}}</strong>.</p>
</div> </div>
<div class="card" id="dns-status"> <div class="card" id="spf-dmarc">
<h2>DNS status <span class="st st-{{.DNS.Overall}}">{{.DNS.Overall}}</span></h2> <h2>SPF and DMARC records</h2>
<p class="muted">Cached a few minutes — use <em>Re-check</em> after
publishing.</p>
<label>DKIM <span class="st st-{{.DNS.DKIM.Status}}">{{.DNS.DKIM.Status}}</span></label>
<p class="{{if eq .DNS.DKIM.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DKIM.Detail}}</p>
{{if .DNS.DKIM.Records}}<span class="code">{{range .DNS.DKIM.Records}}{{.}}
{{end}}</span>{{end}}
<label>SPF <span class="st st-{{.DNS.SPF.Status}}">{{.DNS.SPF.Status}}</span></label>
<p class="{{if eq .DNS.SPF.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.SPF.Detail}}</p>
{{if .DNS.SPF.Records}}<span class="code">{{range .DNS.SPF.Records}}{{.}}
{{end}}</span>{{end}}
<p class="muted">Shallow check: literal address only, no
<code>include:</code> / <code>redirect=</code>.</p>
<label>DMARC <span class="st st-{{.DNS.DMARC.Status}}">{{.DNS.DMARC.Status}}</span></label>
<p class="{{if eq .DNS.DMARC.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DMARC.Detail}}</p>
{{if .DNS.DMARC.Records}}<span class="code">{{range .DNS.DMARC.Records}}{{.}}
{{end}}</span>{{end}}
{{if .DNS.DMARCReportAuth.Status}}
<label>Report authorization <span class="st st-{{.DNS.DMARCReportAuth.Status}}">{{.DNS.DMARCReportAuth.Status}}</span></label>
<p class="{{if eq .DNS.DMARCReportAuth.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DMARCReportAuth.Detail}}</p>
{{if .DNS.DMARCReportAuth.Records}}<span class="code">{{range .DNS.DMARCReportAuth.Records}}{{.}}
{{end}}</span>{{end}}
{{end}}
<form class="inline" method="post" action="/domains/{{.Domain.ID}}/dns-recheck">
<button type="submit">Re-check</button>
</form>
</div>
</div>
<div class="split">
<div class="card" id="spf">
<h2>SPF record</h2>
<p class="muted">Suggested TXT. If the domain already has SPF, merge this
server's mechanism into it — do not add a second record.</p>
<p class="check-col-title">SPF</p>
<label>Host / name</label> <label>Host / name</label>
<div class="code-row"> <div class="code-row">
<span class="code">{{.Domain.Name}}</span> <span class="code">{{.Domain.Name}}</span>
<button type="button" class="copy">Copy</button> <button type="button" class="copy">Copy</button>
</div> </div>
<label>Type</label>
<span class="code">TXT</span>
<label>Value</label> <label>Value</label>
<div class="code-row"> <div class="code-row">
<span class="code">{{.SPFExample}}</span> <span class="code">{{.SPFExample}}</span>
<button type="button" class="copy">Copy</button> <button type="button" class="copy">Copy</button>
</div> </div>
</div>
<div class="card" id="dmarc"> <p class="muted">Merge into an existing SPF if the domain already has one —
<h2>DMARC record</h2> do not publish a second record.</p>
<p class="muted">Policy TXT. SelfPost is send-only — omit <code>rua=</code>
or point it at a mailbox elsewhere (<a href="/settings">Settings</a> default
or custom below).</p>
<form method="post" action="/domains/{{.Domain.ID}}/dmarc">
<label for="dmarc_rua_mode">Aggregate reports (rua=)</label>
<select id="dmarc_rua_mode" name="dmarc_rua_mode">
<option value="inherit"{{if eq .DMARCRuaMode "inherit"}} selected{{end}}>Same as Settings{{if .ProfileDMARCEmail}} ({{.ProfileDMARCEmail}}){{end}}</option>
<option value="none"{{if eq .DMARCRuaMode "none"}} selected{{end}}>No aggregate reports</option>
<option value="custom"{{if eq .DMARCRuaMode "custom"}} selected{{end}}>Custom address</option>
</select>
<label for="dmarc_rua_email">Custom report address</label>
<input id="dmarc_rua_email" name="dmarc_rua_email" type="email"
autocapitalize="none" spellcheck="false" value="{{.DMARCRuaCustom}}"
placeholder="reports@your-mail-domain.com">
<button type="submit">Save DMARC report settings</button>
</form>
<p class="check-col-title">DMARC</p>
<label>Host / name</label> <label>Host / name</label>
<div class="code-row"> <div class="code-row">
<span class="code">{{.DMARCName}}</span> <span class="code">{{.DMARCName}}</span>
<button type="button" class="copy">Copy</button> <button type="button" class="copy">Copy</button>
</div> </div>
<label>Type</label>
<span class="code">TXT</span>
<label>Value{{if eq .DMARCSource "settings"}} <span class="muted">(from Settings)</span>{{else if eq .DMARCSource "custom"}} <span class="muted">(custom)</span>{{else if eq .DMARCSource "none"}} <span class="muted">(no reports)</span>{{end}}</label> <label>Value{{if eq .DMARCSource "settings"}} <span class="muted">(from Settings)</span>{{else if eq .DMARCSource "custom"}} <span class="muted">(custom)</span>{{else if eq .DMARCSource "none"}} <span class="muted">(no reports)</span>{{end}}</label>
<div class="code-row"> <div class="code-row">
<span class="code">{{.DMARCExample}}</span> <span class="code">{{.DMARCExample}}</span>
@@ -147,13 +150,17 @@
{{end}} {{end}}
{{if .NeedsReportAuth}} {{if .NeedsReportAuth}}
<label>Report authorization — host / name</label> <p class="check-col-title">Report authorization</p>
<label>Host / name</label>
<div class="code-row"> <div class="code-row">
<span class="code">{{.ReportAuthName}}</span> <span class="code">{{.ReportAuthName}}</span>
<button type="button" class="copy">Copy</button> <button type="button" class="copy">Copy</button>
</div> </div>
<label>Report authorization — value</label> <label>Type</label>
<span class="code">TXT</span>
<label>Value</label>
<div class="code-row"> <div class="code-row">
<span class="code">{{.ReportAuthValue}}</span> <span class="code">{{.ReportAuthValue}}</span>
<button type="button" class="copy">Copy</button> <button type="button" class="copy">Copy</button>
@@ -161,7 +168,8 @@
{{end}} {{end}}
<p class="muted"><code>p=none</code> does not affect delivery. Tighten to <p class="muted"><code>p=none</code> does not affect delivery. Tighten to
<code>p=quarantine</code> then <code>p=reject</code> once reports look clean.</p> <code>p=quarantine</code> then <code>p=reject</code> once reports look clean.
Report address is set under <a href="#domain-settings">Domain settings</a>.</p>
</div> </div>
</div> </div>
@@ -229,17 +237,12 @@
{{if eq .AddressMode $.Wildcard}}Any address of the domain — *@{{$.Domain.Name}} {{if eq .AddressMode $.Wildcard}}Any address of the domain — *@{{$.Domain.Name}}
{{else}}Fixed list — {{range $i, $a := .Addresses}}{{if $i}}, {{end}}{{$a}}{{end}}{{end}} {{else}}Fixed list — {{range $i, $a := .Addresses}}{{if $i}}, {{end}}{{$a}}{{end}}{{end}}
</p> </p>
<!-- The two panels are opened by a checkbox and a label rather than by <!-- One Edit panel (mode ‖ rate limit) opened by a checkbox and label
<details>, so that the controls stay a single row of buttons and rather than <details>, so the button row stays intact — see
what a panel reveals is laid out under all four of them instead of .panel-toggle in panel.css. -->
splitting the row in two. The checkbox precedes both its label and
its panel, which is what lets the CSS alone show them (no script
involved) — see .panel-toggle in panel.css. -->
<div class="actions"> <div class="actions">
<input class="panel-toggle t-mode" id="mode-{{.ID}}" type="checkbox"> <input class="panel-toggle t-edit" id="edit-{{.ID}}" type="checkbox">
<label class="toggle for-mode" for="mode-{{.ID}}">Edit mode</label> <label class="toggle for-edit" for="edit-{{.ID}}">Edit{{if .HasLimit}} (limit active){{end}}</label>
<input class="panel-toggle t-limit" id="limit-{{.ID}}" type="checkbox">
<label class="toggle for-limit" for="limit-{{.ID}}">Rate limit{{if .HasLimit}} (active){{end}}</label>
<form class="inline" method="post" action="/applications/{{.ID}}/password" <form class="inline" method="post" action="/applications/{{.ID}}/password"
data-confirm="Regenerate the password for {{.Login}}? The current password stops working immediately."> data-confirm="Regenerate the password for {{.Login}}? The current password stops working immediately.">
<button type="submit">New password</button> <button type="submit">New password</button>
@@ -248,44 +251,55 @@
data-confirm="Delete application {{.Login}}? Its credentials stop working immediately."> data-confirm="Delete application {{.Login}}? Its credentials stop working immediately.">
<button type="submit" class="danger">Delete</button> <button type="submit" class="danger">Delete</button>
</form> </form>
<div class="panel panel-mode"> <div class="panel panel-edit">
<form method="post" action="/applications/{{.ID}}/mode"> <div class="check-cols">
<label>Address mode</label> <div class="check-col">
<select name="mode" data-list-mode="{{$.List}}"> <p class="check-col-title">Address mode</p>
<option value="{{$.Wildcard}}" {{if eq .AddressMode $.Wildcard}}selected{{end}}>Any address of the domain</option> <form method="post" action="/applications/{{.ID}}/mode">
<option value="{{$.List}}" {{if eq .AddressMode $.List}}selected{{end}}>Specific addresses (list)</option> <label>Address mode</label>
</select> <select name="mode" data-list-mode="{{$.List}}">
<div data-addresses> <option value="{{$.Wildcard}}" {{if eq .AddressMode $.Wildcard}}selected{{end}}>Any address of the domain</option>
<label>Addresses (one per line or comma-separated)</label> <option value="{{$.List}}" {{if eq .AddressMode $.List}}selected{{end}}>Specific addresses (list)</option>
<textarea name="addresses" rows="3" placeholder="alerts@{{$.Domain.Name}}">{{range $i, $a := .Addresses}}{{if $i}} </select>
<div data-addresses>
<label>Addresses (one per line or comma-separated)</label>
<textarea name="addresses" rows="3" placeholder="alerts@{{$.Domain.Name}}">{{range $i, $a := .Addresses}}{{if $i}}
{{end}}{{$a}}{{end}}</textarea> {{end}}{{$a}}{{end}}</textarea>
</div>
<button type="submit">Save mode</button>
</form>
</div>
<div class="check-col">
<p class="check-col-title">Rate limit</p>
<form id="rl-{{.ID}}" method="post" action="/applications/{{.ID}}/ratelimit">
<label>Expected client IPs (one per line or comma-separated)</label>
<textarea name="allowed_ips" rows="2" placeholder="203.0.113.10">{{.IPsText}}</textarea>
<p class="muted">The limit counts only connections from these
IPs. Leave empty to leave the limit inactive.</p>
<div class="field-pair">
<div>
<label>Message limit</label>
<input name="max_messages" type="number" min="1" value="{{.MaxText}}" placeholder="500">
</div>
<div>
<label>Window (seconds)</label>
<input name="window_seconds" type="number" min="1" value="{{.WindowVal}}">
</div>
</div>
</form>
<!-- Saving and removing the limit are two posts; the Save
button is bound by form= id so both buttons share a row. -->
<div class="panel-buttons">
<button type="submit" form="rl-{{.ID}}">Save limit</button>
{{if .HasLimit}}
<form class="inline" method="post" action="/applications/{{.ID}}/ratelimit"
data-confirm="Remove the rate limit for {{.Login}}? Only the global level-1 limit will apply.">
<input type="hidden" name="clear" value="1">
<button type="submit" class="danger">Remove limit</button>
</form>
{{end}}
</div>
</div> </div>
<button type="submit">Save mode</button>
</form>
</div>
<div class="panel panel-limit">
<form id="rl-{{.ID}}" method="post" action="/applications/{{.ID}}/ratelimit">
<label>Expected client IPs (one per line or comma-separated)</label>
<textarea name="allowed_ips" rows="2" placeholder="203.0.113.10">{{.IPsText}}</textarea>
<label>Message limit</label>
<input name="max_messages" type="number" min="1" value="{{.MaxText}}" placeholder="500">
<label>Window (seconds)</label>
<input name="window_seconds" type="number" min="1" value="{{.WindowVal}}">
</form>
<!-- Saving and removing the limit are two posts, so they are two
forms and could not share a line while the Save button sat
inside the block form above. It is bound to that form by id
instead (the form attribute, no script involved), which lets
both buttons stand together in one row under the fields. -->
<div class="panel-buttons">
<button type="submit" form="rl-{{.ID}}">Save limit</button>
{{if .HasLimit}}
<form class="inline" method="post" action="/applications/{{.ID}}/ratelimit"
data-confirm="Remove the rate limit for {{.Login}}? Only the global level-1 limit will apply.">
<input type="hidden" name="clear" value="1">
<button type="submit" class="danger">Remove limit</button>
</form>
{{end}}
</div> </div>
</div> </div>
</div> </div>
@@ -297,42 +311,73 @@
{{end}} {{end}}
</div> </div>
<div class="split"> <div class="card" id="domain-settings">
<div class="card" id="rate-limit"> <h2>Domain settings</h2>
<h2>Sending rate limit (domain)</h2>
<p class="muted">Optional level-2 cap across this domain's applications from
the listed client IPs. Empty IP list = inactive (level-1 only).</p>
<p class="muted">Status: <div class="check-cols">
{{if .DomainHasRL}}<strong>active</strong>{{else}}inactive (level-1 only){{end}}.</p> <div class="check-col">
<p class="check-col-title">DMARC reports</p>
<p class="muted">Default comes from <a href="/settings">Settings</a>;
override per domain here.</p>
<form method="post" action="/domains/{{.Domain.ID}}/dmarc">
<label for="dmarc_rua_mode">Aggregate reports (rua=)</label>
<select id="dmarc_rua_mode" name="dmarc_rua_mode" data-custom-mode="custom">
<option value="inherit"{{if eq .DMARCRuaMode "inherit"}} selected{{end}}>Same as Settings{{if .ProfileDMARCEmail}} ({{.ProfileDMARCEmail}}){{end}}</option>
<option value="none"{{if eq .DMARCRuaMode "none"}} selected{{end}}>No aggregate reports</option>
<option value="custom"{{if eq .DMARCRuaMode "custom"}} selected{{end}}>Custom address</option>
</select>
<form method="post" action="/domains/{{.Domain.ID}}/ratelimit"> <div data-custom-address>
<label for="d_ips">Expected client IPs (one per line or comma-separated)</label> <label for="dmarc_rua_email">Custom report address</label>
<textarea id="d_ips" name="allowed_ips" rows="2" <input id="dmarc_rua_email" name="dmarc_rua_email" type="email"
placeholder="203.0.113.10">{{.DomainRLIPs}}</textarea> autocapitalize="none" spellcheck="false" value="{{.DMARCRuaCustom}}"
placeholder="reports@your-mail-domain.com">
</div>
<label for="d_max">Message limit</label> <button type="submit">Save DMARC report settings</button>
<input id="d_max" name="max_messages" type="number" min="1" </form>
value="{{.DomainRLMax}}" placeholder="1000"> </div>
<label for="d_win">Window (seconds)</label> <div class="check-col">
<input id="d_win" name="window_seconds" type="number" min="1" value="{{.DomainRLWin}}"> <p class="check-col-title">Optional level-2 sending rate limit</p>
<p class="muted">Empty IP list = inactive (level-1 only). Status:
{{if .DomainHasRL}}<strong>active</strong>{{else}}inactive{{end}}.</p>
<button type="submit">Save limit</button> <form method="post" action="/domains/{{.Domain.ID}}/ratelimit">
</form> <label for="d_ips">Expected client IPs (one per line or comma-separated)</label>
{{if .DomainHasRL}} <textarea id="d_ips" name="allowed_ips" rows="2"
<form class="inline" method="post" action="/domains/{{.Domain.ID}}/ratelimit" placeholder="203.0.113.10">{{.DomainRLIPs}}</textarea>
data-confirm="Remove the domain rate limit? Only the global level-1 limit will apply."> <p class="muted">The limit counts only connections from these IPs.
<input type="hidden" name="clear" value="1"> Leave empty to leave the limit inactive.</p>
<button type="submit" class="danger">Remove limit</button>
</form> <div class="field-pair">
{{end}} <div>
<label for="d_max">Message limit</label>
<input id="d_max" name="max_messages" type="number" min="1"
value="{{.DomainRLMax}}" placeholder="1000">
</div>
<div>
<label for="d_win">Window (seconds)</label>
<input id="d_win" name="window_seconds" type="number" min="1" value="{{.DomainRLWin}}">
</div>
</div>
<button type="submit">Save limit</button>
</form>
{{if .DomainHasRL}}
<form class="inline" method="post" action="/domains/{{.Domain.ID}}/ratelimit"
data-confirm="Remove the domain rate limit? Only the global level-1 limit will apply.">
<input type="hidden" name="clear" value="1">
<button type="submit" class="danger">Remove limit</button>
</form>
{{end}}
</div>
</div> </div>
</div>
<div class="split">
<div class="card" id="export"> <div class="card" id="export">
<h2>Export domain</h2> <h2>Export domain</h2>
<p class="muted">DKIM key, selector and application passwords for another
SelfPost instance. DNS stays the same on import.</p>
<p class="muted"><strong>Secret file</strong> — transfer securely, or encrypt <p class="muted"><strong>Secret file</strong> — transfer securely, or encrypt
below as <code>.spde</code>.</p> below as <code>.spde</code>.</p>
{{if .ExportErr}}<p class="error">{{.ExportErr}}</p>{{end}} {{if .ExportErr}}<p class="error">{{.ExportErr}}</p>{{end}}
@@ -341,30 +386,11 @@
<button type="submit">Export domain</button> <button type="submit">Export domain</button>
</form> </form>
</div> </div>
</div>
<div class="card" id="danger"> <div class="card" id="danger">
<h2>Danger zone</h2> <h2>Danger zone</h2>
<p class="muted">Deletes the DKIM key and every application on this domain.</p> <p class="muted">Deletes the DKIM key and every application on this domain.</p>
<a class="danger" href="/domains/{{.Domain.ID}}/delete">Delete domain</a> <a class="danger" href="/domains/{{.Domain.ID}}/delete">Delete domain</a>
</div> </div>
{{end}}
{{/* Section index in the navigation column (layout.html). The freshly generated
password is only listed while it is on the page. */}}
{{define "sections"}}
<div class="sections">
<p class="sections-title">On this page</p>
{{if .NewCred}}<a href="#new-credential">New application password</a>{{end}}
<a href="#dkim">DKIM DNS record</a>
<a href="#dns-status">DNS status</a>
<a href="#spf">SPF record</a>
<a href="#dmarc">DMARC record</a>
<a href="#settings">Sending server settings</a>
<a href="#add-application">Add an application</a>
<a href="#applications">Applications</a>
<a href="#rate-limit">Sending rate limit</a>
<a href="#export">Export domain</a>
<a href="#danger">Danger zone</a>
</div> </div>
{{end}} {{end}}
+8 -19
View File
@@ -61,9 +61,9 @@
{{define "nav"}} {{define "nav"}}
<nav class="nav"> <nav class="nav">
{{/* The order of the blocks is the order they are read in, top to bottom of {{/* The order of the blocks is the order they are read in, top to bottom of
the column: the mark, the panel's pages, the current page's own sections, the column: the mark, the panel's pages, and the session last. The mark
and the session last. The mark goes to /status, the page the panel opens goes to /status, the page the panel opens on — named outright rather
on — named outright rather than as "/", which is only a redirect to it. */}} than as "/", which is only a redirect to it. */}}
<a class="brand" href="{{if .IsGlobal}}/status{{else}}/domains{{end}}"><img src="/static/logo-compact.svg" width="220" height="100" alt="SelfPost"></a> <a class="brand" href="{{if .IsGlobal}}/status{{else}}/domains{{end}}"><img src="/static/logo-compact.svg" width="220" height="100" alt="SelfPost"></a>
<div class="links"> <div class="links">
{{if .IsGlobal}} {{if .IsGlobal}}
@@ -78,7 +78,6 @@
{{if eq .Active "users"}}<span aria-current="page">{{template "icon-users"}}Users</span>{{else}}<a href="/users">{{template "icon-users"}}Users</a>{{end}} {{if eq .Active "users"}}<span aria-current="page">{{template "icon-users"}}Users</span>{{else}}<a href="/users">{{template "icon-users"}}Users</a>{{end}}
{{end}} {{end}}
</div> </div>
{{template "sections" .}}
<div class="session"> <div class="session">
<span class="session-user muted">{{template "icon-account"}}User: {{.User}}</span> <span class="session-user muted">{{template "icon-account"}}User: {{.User}}</span>
{{if eq .Active "settings"}}<span aria-current="page">{{template "icon-settings"}}Settings</span>{{else}}<a href="/settings">{{template "icon-settings"}}Settings</a>{{end}} {{if eq .Active "settings"}}<span aria-current="page">{{template "icon-settings"}}Settings</span>{{else}}<a href="/settings">{{template "icon-settings"}}Settings</a>{{end}}
@@ -89,22 +88,12 @@
</nav> </nav>
{{end}} {{end}}
{{/* The index of the current page's own sections, shown in the navigation
column under the page entries. Most pages are one or two cards and need no
index, so the block is empty here and only the pages that are genuinely
long — the domain page, the status page — redefine it with their own list
(see .sections in panel.css for what it looks like, and panel.js for the
marking of the section in view). Each page's template file is parsed after
this one, so its definition replaces this empty one; a page that defines
nothing keeps it and renders no index. */}}
{{define "sections"}}{{end}}
{{/* Whether the page takes the column whole. Empty here, so a page is held to {{/* Whether the page takes the column whole. Empty here, so a page is held to
the reading measure unless it says otherwise; the three pages that are the reading measure unless it says otherwise; the pages that are tables of
tables of data or raw log lines redefine this as the word "wide", which data, raw log lines or side-by-side cards redefine this as the word "wide",
lands in <main>'s class list (see main.wide in panel.css). Same mechanism which lands in <main>'s class list (see main.wide in panel.css). Each
as "sections" above: each page's template file is parsed after this one, page's template file is parsed after this one, so its definition replaces
so its definition replaces the empty one. */}} the empty one. */}}
{{define "wide"}}{{end}} {{define "wide"}}{{end}}
{{/* back_link — up-navigation on drill-down pages. Invoke with the "back" {{/* back_link — up-navigation on drill-down pages. Invoke with the "back"
+26 -83
View File
@@ -29,80 +29,6 @@ func TestEveryPageResolvesNav(t *testing.T) {
} }
} }
// The section index each long page shows in the navigation column works by
// overriding an empty "sections" block defined in the layout, which only holds
// as long as the layout is parsed before the page's own files (see pageFiles).
// Reverse that order and every index would silently disappear — the empty
// definition would win and no page would fail to render — so the two ends are
// asserted here: the long pages produce a list, and a page that defines nothing
// produces nothing at all.
func TestSectionIndexIsOnTheLongPagesOnly(t *testing.T) {
engine, err := New("test")
if err != nil {
t.Fatalf("New: %v", err)
}
// Anchors the index links to, taken from the page's own cards. Status used
// to carry one too; the paired layout is short enough without it.
wantAnchors := map[string]string{
"domain_detail": `href="#danger"`,
}
for name, page := range engine.Pages() {
var buf bytes.Buffer
// The domain page's index hides the freshly generated credential entry
// unless one is on the page, so the data map carries the key it reads.
if err := page.ExecuteTemplate(&buf, "sections", map[string]any{"NewCred": nil}); err != nil {
t.Fatalf("execute sections for %q: %v", name, err)
}
out := buf.String()
anchor, wanted := wantAnchors[name]
switch {
case wanted && !strings.Contains(out, anchor):
t.Errorf("page %q shows no section index (expected %s):\n%s", name, anchor, out)
case !wanted && strings.TrimSpace(out) != "":
t.Errorf("page %q is not long enough to carry a section index:\n%s", name, out)
}
}
}
// A section link that points at no card is a link that does nothing, and
// nothing about rendering the page says so. Every anchor the index offers must
// name an element the same page defines an id for.
func TestSectionLinksPointAtCardsThatExist(t *testing.T) {
engine, err := New("test")
if err != nil {
t.Fatalf("New: %v", err)
}
// The pages that carry an index; checked with a credential shown, which is
// the domain page's one conditional entry. Status dropped its index once
// the paired layout was short enough.
for _, name := range []string{"domain_detail"} {
var index bytes.Buffer
if err := engine.Page(name).ExecuteTemplate(&index, "sections", map[string]any{"NewCred": true}); err != nil {
t.Fatalf("execute sections for %q: %v", name, err)
}
// The cards are spread over the page's template files, so the ids are
// collected from the files rather than from a rendered page — rendering
// one would need the whole of a handler's data map.
ids := map[string]bool{}
for _, file := range pageFiles[name] {
body, err := fs.ReadFile(assetsFS, file)
if err != nil {
t.Fatalf("read %s: %v", file, err)
}
// Cards only: a form field's id is not somewhere a section link may
// land, so matching those too would weaken the check.
for _, m := range regexp.MustCompile(`class="card[^"]*" id="([a-z-]+)"`).FindAllStringSubmatch(string(body), -1) {
ids[m[1]] = true
}
}
for _, m := range regexp.MustCompile(`href="#([a-z-]+)"`).FindAllStringSubmatch(index.String(), -1) {
if !ids[m[1]] {
t.Errorf("page %q indexes #%s, which no card on it carries", name, m[1])
}
}
}
}
// The version comes from render(), not from each handler's data map, so the // The version comes from render(), not from each handler's data map, so the
// footer is only correct as long as every page composes with the layout and // footer is only correct as long as every page composes with the layout and
// render keeps supplying the key. Both are asserted here rather than trusted. // render keeps supplying the key. Both are asserted here rather than trusted.
@@ -280,29 +206,46 @@ func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) {
} }
} }
// The domain page pairs cards the same way Status does: four .split rows // The domain page pairs cards the same way Status does: three .split rows
// (DKIM|DNS, SPF|DMARC, settings|add-app, rate-limit|export) with Applications // (DKIM|SPF+DMARC, settings|add-app, export|danger). DNS status, Applications
// and Danger full-width. Losing a row silently stacks the page again. // and Domain settings are full-width; DNS status and Domain settings (and the
// application Edit panel) use .check-cols. Losing a row silently stacks again.
func TestDomainDetailPageHasPairedCards(t *testing.T) { func TestDomainDetailPageHasPairedCards(t *testing.T) {
body, err := fs.ReadFile(assetsFS, "templates/domain_detail.html") body, err := fs.ReadFile(assetsFS, "templates/domain_detail.html")
if err != nil { if err != nil {
t.Fatalf("read domain_detail: %v", err) t.Fatalf("read domain_detail: %v", err)
} }
src := string(body) src := string(body)
if got := strings.Count(src, `class="split"`); got != 4 { if got := strings.Count(src, `class="split"`); got != 3 {
t.Errorf("domain detail has %d .split rows, want 4", got) t.Errorf("domain detail has %d .split rows, want 3", got)
}
if !strings.Contains(src, `class="check-cols"`) {
t.Error("domain detail is missing the check-cols grid")
}
if !strings.Contains(src, `class="panel-toggle t-edit"`) {
t.Error("application Edit should be a single panel-toggle")
}
if strings.Contains(src, `panel-toggle t-mode`) || strings.Contains(src, `panel-toggle t-limit`) ||
strings.Contains(src, `panel-mode`) || strings.Contains(src, `panel-limit`) {
t.Error("application Edit mode and Rate limit should be one Edit button")
} }
for _, id := range []string{ for _, id := range []string{
`id="dkim"`, `id="dns-status"`, `id="spf"`, `id="dmarc"`, `id="dkim"`, `id="dns-status"`, `id="spf-dmarc"`,
`id="settings"`, `id="add-application"`, `id="applications"`, `id="settings"`, `id="add-application"`, `id="applications"`,
`id="rate-limit"`, `id="export"`, `id="danger"`, `id="domain-settings"`, `id="export"`, `id="danger"`,
} { } {
if !strings.Contains(src, id) { if !strings.Contains(src, id) {
t.Errorf("domain detail is missing %s", id) t.Errorf("domain detail is missing %s", id)
} }
} }
if strings.Contains(src, `id="spf-dmarc"`) { if strings.Contains(src, `id="rate-limit"`) {
t.Error("domain detail still has the combined spf-dmarc card; SPF and DMARC are separate") t.Error("domain rate limit should live inside domain-settings, not its own card")
}
if strings.Contains(src, `id="spf"`) && !strings.Contains(src, `id="spf-dmarc"`) {
t.Error("standalone SPF card should be merged into spf-dmarc")
}
if regexp.MustCompile(`id="dmarc"`).MatchString(src) {
t.Error("standalone DMARC card should be merged into spf-dmarc")
} }
} }