release: 1.2.0: Panel 1.2.0-1.2.5: layout, RBAC, tests, GUI and CI fixes

[1.2.0]
The second MINOR after 1.0.0: domain administrators with per-domain scope, a panel visual refresh on the SelfPost palette, and refinements to navigation and the send log. Upgrading runs one SQLite migration (`0005_panel_users`); the single administrator becomes a global user.
Added:
  - panel: **domain-admin role** — global administrators manage panel users and
Fixed:
  - Docker build: `LICENSE` is no longer excluded by `.dockerignore`, so the
  - panel: on signed-in pages with a narrow card (**Settings**, the user form)
Changed:
  - panel: navigation session icons are distinct — **Settings** uses a gear,
  - panel: `/` redirects domain administrators to `/domains`; global users still
  - panel: **Sign out** in the navigation column uses the same type size and
  - panel: **visual style** brought in line with the SelfPost mark — brick accent
  - panel: the send log's **status is a badge**, in the same ok/warn/error/unknown
  … (1 more in CHANGELOG)
[1.2.1]
Panel refinements after 1.2.0: navigation icons, status-badge centreing, drill-down back-link placement, and safeguards for the sole global administrator. Upgrading is a tag bump; no migration.
Fixed:
  - panel: status badge text sat low in the box (and below the heading or label
  - panel: the **Users** navigation icon was two full silhouettes with staggered
  - panel: the **Settings** navigation icon was a sun-with-rays (circle plus
  - panel: the user create/edit form placed «Back to users» at the bottom of the
Changed:
  - panel: the user edit form disables role change and delete for the only global
  - panel: the user create/edit form hides **Assigned domains** when the role is
  - panel: **Settings** shows panel credentials and DMARC aggregate reports side
[1.2.2]
Status page layout after 1.2.1: paired cards in a wide column, denser machine and check copy, and a user-form checkbox fix. Upgrading is a tag bump; no migration.
Changed:
  - panel: **Status** is wide again so paired cards fill the column. Layout:
Fixed:
  - panel: cards inside `.split` used `margin: 0 auto`, which in a CSS grid
  - panel: on the user create/edit form, **Assigned domains** checkboxes stacked
[1.2.3]
Domain detail layout and panel polish after 1.2.2. Upgrading is a tag bump; no migration.
Changed:
  - panel: the domain detail page is wide with paired cards (DKIM+SPF ‖ DMARC;
  - panel: Domains list — **Add domain** sits beside the domain field; the
  - panel: page URLs, browser titles, and headings are aligned — **Settings** is
Fixed:
  - image: `mail.log` rotation no longer silently stops when the build context
[1.2.4]
Level-2 rate-limit semantics inverted after 1.2.3, plus a small DNS field height fix. Upgrading is a tag bump; no migration.
Changed:
  - rate limiting (level 2): domain ceilings apply to every client IP (no IP
Fixed:
  - panel: on the domain DNS status grid, the Type (TXT) field height matches
[1.2.5]
Rate-limit form polish after 1.2.4. Upgrading is a tag bump; no migration.
Changed:
  - panel: rate-limit forms refined — level-1 backstop as muted copy on the

Squashed CHANGELOG sections: [1.2.0], [1.2.1], [1.2.2], [1.2.3], [1.2.4], [1.2.5].

Full detail: CHANGELOG.md (1.2.0, 1.2.1, 1.2.2, 1.2.3, 1.2.4, 1.2.5).
This commit is contained in:
mix
2026-09-22 15:24:52 +03:00
parent f62fe23826
commit 60e08e802e
73 changed files with 3614 additions and 1517 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
+347 -129
View File
@@ -2,7 +2,36 @@
layout so the panel's Content-Security-Policy can be a plain
"default-src 'self'" with no inline-style exemption. Any rule
added here must therefore stay here: an inline style="..." attribute in a
template is blocked by that policy and silently does nothing. */
template is blocked by that policy and silently does nothing.
The palette and the type below come from the mark's own approval sheet
(docs/assets/selfpost-proof.html) — brick, warm paper, IBM Plex. Before it
the panel was a default blue-on-cool-grey admin theme with the stamp pasted
onto it, and the two read as different pieces of work. */
/* The fonts ship with the panel rather than being asked for by name. Two
reasons, and the second is the one that matters. A named family resolves to
whatever the operator's machine has, so the mark — Plex converted to
outlines — would be the only Plex on the page. And ui-monospace is Consolas,
SF Mono or DejaVu Sans Mono depending on that machine, each with its own
advance width: the six-column send log and the mail.log tables would lay out
differently for every operator. Served from the panel's own origin, so the
policy above already covers them and needs no font-src of its own.
One variable file for the sans (100–700 in 46 KB, less than the four static
weights it replaces); mono has no variable release, so it is two files.
swap, so a cold load shows the system stack for a frame instead of nothing. */
@font-face {
font-family: "IBM Plex Sans"; font-style: normal; font-weight: 100 700;
font-display: swap; src: url("/static/ibm-plex-sans.woff2") format("woff2");
}
@font-face {
font-family: "IBM Plex Mono"; font-style: normal; font-weight: 400;
font-display: swap; src: url("/static/ibm-plex-mono-400.woff2") format("woff2");
}
@font-face {
font-family: "IBM Plex Mono"; font-style: normal; font-weight: 600;
font-display: swap; src: url("/static/ibm-plex-mono-600.woff2") format("woff2");
}
/* Colour tokens. Light values live on :root; the dark media query below
reassigns the same names rather than re-declaring every rule that uses
@@ -10,39 +39,57 @@
property already carries the right value for the active scheme. */
:root {
color-scheme: light dark;
--bg: #f6f7f9; --fg: #1b1f24;
--font-sans: "IBM Plex Sans", system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
--font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
--bg: #f4f2ed; --fg: #12161c;
--muted: #6b7684; /* secondary text: .muted, th, timestamps, footers */
--card-bg: #fff;
--border: #e2e5e9; /* dividers: card, table, app, nav, code, encrypt-fields */
--control-border: #cfd4da; /* input, select, textarea, action buttons */
--border: #dedcd7; /* dividers: card, table, app, nav, code, encrypt-fields */
--control-border: #cbc8c1; /* input, select, textarea, action buttons */
--input-bg: #fff;
--code-bg: #f0f2f4;
--surface-bg: #eef1f5; --surface-bg-hover: #e2e7ee; --surface-open-bg: #dde3ec;
--nav-active-bg: #e6ebf5;
--flash-bg: #ecfdf3; --flash-border: #abefc6; --flash-fg: #067647;
--credential-bg: #fffbeb; --credential-border: #f5c518;
--danger-bg: #fef3f2; --danger-border: #fecdca; --danger-fg: #b42318; --danger-bg-hover: #fee4e2;
--st-ok-bg: #ecfdf3; --st-ok-fg: #067647; --st-ok-border: #abefc6;
--st-warn-bg: #fffaeb; --st-warn-fg: #b54708; --st-warn-border: #fedf89;
--st-error-bg: #fef3f2; --st-error-fg: #b42318; --st-error-border: #fecdca;
--st-unknown-bg: #f0f2f4; --st-unknown-fg: #6b7280; --st-unknown-border: #e2e5e9;
--code-bg: #efede9;
--surface-bg: #eae7e0; --surface-bg-hover: #e0dcd3; --surface-open-bg: #d8d3c8;
--nav-active-bg: #ede4de;
/* Brick, the mark's own colour, is the panel's accent — but as two values,
not one. A filled control carries --accent-fill with white on it; a link or
an active entry carries --accent-text, because the fill on a page
background is a colour to sit behind text, not to be text. In the light
scheme the two coincide. */
--accent-fill: #7a3b2e; --accent-fill-hover: #632f25; --accent-text: #7a3b2e;
--on-accent: #fff;
--flash-bg: #edf6ef; --flash-border: #c2e0cc; --flash-fg: #2c6b43;
--credential-bg: #fcf6e4; --credential-border: #e0c874;
--danger-fill: #b42318; --danger-fill-hover: #912018;
--danger-bg: #fbedea; --danger-border: #efccc4; --danger-fg: #b42318; --danger-bg-hover: #f7dfda;
--st-ok-bg: #edf6ef; --st-ok-fg: #2c6b43; --st-ok-border: #c2e0cc;
--st-warn-bg: #fbf2e2; --st-warn-fg: #8a5510; --st-warn-border: #ebd5a6;
--st-error-bg: #fbedea; --st-error-fg: #b42318; --st-error-border: #efccc4;
--st-unknown-bg: #efede9; --st-unknown-fg: #6b7684; --st-unknown-border: #dedcd7;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #14171a; --fg: #e6e8eb;
--card-bg: #1d2125;
--border: #2b3138;
--control-border: #2b3138;
--input-bg: #14171a;
--code-bg: #14171a;
--surface-bg: #22262b; --surface-bg-hover: #2b3138; --surface-open-bg: #313841;
--nav-active-bg: #22303f;
--flash-bg: #0d2818; --flash-border: #1a5336; --flash-fg: #75d99b;
--credential-bg: #2a2408; --credential-border: #6b5a10;
--danger-bg: #2d1211; --danger-border: #6b201a; --danger-fg: #f5a29b; --danger-bg-hover: #3d1a18;
--st-ok-bg: #0d2818; --st-ok-fg: #75d99b; --st-ok-border: #1a5336;
--st-warn-bg: #2e2308; --st-warn-fg: #f5c86b; --st-warn-border: #6b5210;
--st-error-bg: #2d1211; --st-error-fg: #f5a29b; --st-error-border: #6b201a;
--st-unknown-bg: #22262b; --st-unknown-fg: #9aa3ad; --st-unknown-border: #2b3138;
--bg: #16181b; --fg: #e9e6e0;
--muted: #9aa1a9;
--card-bg: #1d2024;
--border: #2c2f34;
--control-border: #3a3e44;
--input-bg: #14161a;
--code-bg: #14161a;
--surface-bg: #23262b; --surface-bg-hover: #2c3036; --surface-open-bg: #343941;
--nav-active-bg: #2a1f1b;
/* Brick as drawn is about 2:1 against this background — a link nobody can
read. The fill lightens just enough to keep white on it above 4.5:1
(6.9:1 as set), and the text value lightens further to clear the same bar
as text on the page. This is where the two values earn their keep. */
--accent-fill: #8e4535; --accent-fill-hover: #a0503e; --accent-text: #ce7b66;
--flash-bg: #132318; --flash-border: #22452f; --flash-fg: #7fcb9b;
--credential-bg: #26210d; --credential-border: #5e5013;
--danger-fill: #9b2c22; --danger-fill-hover: #b0342a;
--danger-bg: #2a1412; --danger-border: #5e2721; --danger-fg: #eb9b92; --danger-bg-hover: #381a17;
--st-ok-bg: #132318; --st-ok-fg: #7fcb9b; --st-ok-border: #22452f;
--st-warn-bg: #2a2109; --st-warn-fg: #e5be72; --st-warn-border: #5e4b12;
--st-error-bg: #2a1412; --st-error-fg: #eb9b92; --st-error-border: #5e2721;
--st-unknown-bg: #23262b; --st-unknown-fg: #9aa1a9; --st-unknown-border: #2c2f34;
}
}
* { box-sizing: border-box; }
@@ -54,7 +101,7 @@
read as the layout jumping. */
html { scrollbar-gutter: stable; }
body {
font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
font: 400 15px/1.5 var(--font-sans);
margin: 0; padding: 2rem 1rem; background: var(--bg); color: var(--fg);
}
/* The two columns — navigation on the left, page on the right — centred as a
@@ -93,7 +140,11 @@ main.wide > * { max-width: none; }
/* Longhands, not the margin shorthand: the shorthand would re-zero the auto
side margins above and pin the heading to the column's edge, a measure's
worth to the left of the card under it. */
h1 { font-size: 1.4rem; margin-top: 0; margin-bottom: 1rem; }
/* Light rather than bold, which is the sheet's own setting for the name and
the one thing a variable font makes free. It is the page's title, not an
alarm: the size already says which line is the heading, and at 700 it shouted
over the card under it. */
h1 { font-size: 1.5rem; font-weight: 300; letter-spacing: -0.01em; margin-top: 0; margin-bottom: 1rem; }
/* The full mark, on the two pages that have no navigation bar to carry the
compact one. It takes the column's width so its edges line up with the card
below it, capped at that column's own 24rem; height stays automatic because
@@ -101,16 +152,50 @@ h1 { font-size: 1.4rem; margin-top: 0; margin-bottom: 1rem; }
The subtitle stops resolving below 280px, which a 320px viewport still clears
once the body's padding is taken off. */
.mark { display: block; width: 100%; max-width: 24rem; height: auto; margin-bottom: 1.4rem; }
/* The radius comes down from 10px across the panel: the mark is a stamp with a
square edge and a hairline, and a card rounded like a phone widget sat in
front of it as a different kind of object. 6px on the containers, 5px on the
controls inside them. */
.card {
background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px;
background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px;
padding: 1.5rem; margin: 0 auto;
}
.card.narrow { max-width: 24rem; }
/* A narrow card as a direct child of main means the page is a short form:
every sibling in the column (heading, flash, footer) must share that 24rem
measure, or they centre on 48rem while the card centres on 24rem and the
left edges drift by half the difference (12rem / 192px at a 16px root —
measured). The column itself stays 64rem so the navigation does not move;
only the stack inside the column narrows. :has tracks the card rather than
a per-page class, so a new signed-in page that adds .card.narrow inherits
the alignment without a second stylesheet rule.
login/setup already shrink main to 24rem (below); this rule is then a no-op
relative to the parent. Do not replace that with :has alone: narrowing main
is what centres the signed-out block on the page when there is no nav. */
main:has(> .card.narrow) > * { max-width: 24rem; }
label { display: block; font-weight: 600; margin: 0.9rem 0 0.3rem; }
input {
width: 100%; padding: 0.55rem 0.7rem; font-size: 1rem;
border: 1px solid var(--control-border); border-radius: 6px; background: var(--input-bg); color: inherit;
/* Checkbox rows (domain assignment, encrypt toggle) opt out of the block
label and full-width input rules above: the box and its caption sit on one
line. Without this the caption drops under a stretched checkbox. */
label.check {
display: flex; align-items: center; gap: 0.5rem; margin: 0.45rem 0 0; font-weight: 600;
cursor: pointer;
}
label.check input { width: auto; margin: 0; padding: 0; border: 0; border-radius: 0; background: none; }
input {
width: 100%; padding: 0.55rem 0.7rem; font-family: inherit; font-size: 1rem;
border: 1px solid var(--control-border); border-radius: 5px; background: var(--input-bg); color: inherit;
}
/* Domain assignment on the user form: a bordered group under Role, not another
stacked field label. Legend replaces the usual label; the muted line is the
hint that used to sit where a field caption would. */
fieldset {
margin: 0.9rem 0 0; padding: 0.55rem 0.85rem 0.85rem;
border: 1px solid var(--control-border); border-radius: 5px;
}
fieldset legend { padding: 0 0.25rem; font-weight: 600; }
fieldset > .muted { margin: 0.15rem 0 0.35rem; font-size: 0.85rem; font-weight: 400; }
fieldset > label.check:first-of-type { margin-top: 0.15rem; }
/* One vocabulary for actions. Anything that performs an action looks like a
button: a <button>, or an <a> carrying .btn/.danger where the action is a
plain navigation (the delete confirmation page, the full queue view).
@@ -123,11 +208,25 @@ input {
button, a.btn, a.danger {
display: inline-block; margin-top: 1.2rem; padding: 0.6rem 1.1rem;
font: inherit; font-size: 1rem; font-weight: 600; text-decoration: none;
color: #fff; background: #2563eb; border: 0; border-radius: 6px; cursor: pointer;
color: var(--on-accent); background: var(--accent-fill); border: 0; border-radius: 5px; cursor: pointer;
}
button:hover, a.btn:hover { background: #1d4ed8; }
.error { color: #b42318; margin: 0.6rem 0 0; font-weight: 600; }
.muted { color: #6b7280; }
button:hover, a.btn:hover { background: var(--accent-fill-hover); }
button:disabled, button.danger:disabled {
opacity: 0.45; cursor: not-allowed;
}
button:disabled:hover { background: var(--accent-fill); }
button.danger:disabled:hover { background: var(--danger-fill); }
select:disabled {
opacity: 0.65; cursor: not-allowed; background: var(--code-bg);
}
.error { color: var(--danger-fg); margin: 0.6rem 0 0; font-weight: 600; }
.muted { color: var(--muted); }
/* Links take the accent. The panel never set a colour here and ran on the
browser's own blue, which passed unnoticed while the accent was itself blue;
against brick it left every domain name and back link belonging to a palette
the panel no longer uses. The controls that are links dressed as buttons
(a.btn, a.danger, a.st) all carry a class and out-specify this. */
a { color: var(--accent-text); }
.topbar { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 1.2rem; }
.topbar .actions { display: flex; gap: 0.9rem; align-items: baseline; }
/* form.inline only exists so a POST can sit next to other content without a
@@ -138,10 +237,14 @@ form.inline { display: inline; margin: 0; }
card floated in the middle while the mark and the heading stayed at the far
left, three alignments on a page with four elements. Narrowing the column to
the card's own width makes the three line up and puts the block as a whole in
the middle of the page. */
the middle of the page.
By page name and not by "the page holds a narrow card": signed-in pages that
hold a narrow card keep the 64rem column (see main:has(> .card.narrow)
above) so the navigation stays put; only these two pages have no nav and
need the column itself narrowed. */
main.page-login, main.page-setup { max-width: 24rem; }
.card + .card { margin-top: 1.2rem; }
.flash { background: var(--flash-bg); border: 1px solid var(--flash-border); color: var(--flash-fg); padding: 0.7rem 1rem; border-radius: 8px; margin-bottom: 1.2rem; }
.flash { background: var(--flash-bg); border: 1px solid var(--flash-border); color: var(--flash-fg); padding: 0.7rem 1rem; border-radius: 6px; margin-bottom: 1.2rem; }
table { width: 100%; border-collapse: collapse; }
/* A table column is at least as wide as the longest unbreakable run inside it,
and the panel's tables are full of runs with nothing to break on: email
@@ -149,11 +252,26 @@ table { width: 100%; border-collapse: collapse; }
the send-log past its card and hang Status over the edge. Cells may break mid
word, so a column can always be squeezed to the width available. */
th, td { text-align: left; padding: 0.5rem 0.4rem; border-bottom: 1px solid var(--border); overflow-wrap: anywhere; }
/* The exception: a timestamp broken across two lines is unreadable, and it is
short enough to never be the reason a row does not fit. */
td.time { white-space: nowrap; }
th { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.03em; color: #6b7280; }
td.actions { text-align: right; }
/* The exceptions: a timestamp broken across two lines is unreadable, and so is
a send-log status — "deferred" was being hyphenless-broken into "deferre" and
"d" whenever the row was tight. Both are short enough to never be the reason
a row does not fit. */
td.time, td.status { white-space: nowrap; }
/* 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
also holds them apart from the first row of values. Same treatment on
.fact-label, so a heading of that size reads as one thing wherever it
appears. */
th {
font-family: var(--font-mono); font-size: 0.75rem; font-weight: 500;
text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted);
}
/* The controls at the end of a row are short and fixed — Details, Delete — and
the cell is the last one, so it is the one the automatic layout squeezes
first: in the send log it was breaking "Details" after "Detail". Holding it
on one line costs the row nothing and hands the width back to the status
column, which was wrapping single words for the same reason. */
td.actions { text-align: right; white-space: nowrap; }
/* Subject is the one cell whose text we do not control. Breaking mid word (the
rule above) keeps it inside the card, but a long subject would do it by
growing the row several lines tall, which buries the rows around it. So the
@@ -164,9 +282,11 @@ td.subject span {
display: block; max-width: 18rem;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.code { display: block; white-space: pre-wrap; word-break: break-all; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.85rem; background: var(--code-bg); border: 1px solid var(--border); border-radius: 6px; padding: 0.7rem 0.8rem; margin: 0.3rem 0 0; }
h2 { font-size: 1.05rem; margin: 0 0 0.4rem; }
.code { display: block; white-space: pre-wrap; word-break: break-all; font-family: var(--font-mono);
font-size: 0.85rem; background: var(--code-bg); border: 1px solid var(--border); border-radius: 5px; padding: 0.7rem 0.8rem; margin: 0.3rem 0 0; }
/* Semibold, not the browser's bold: a card's title sits a step under the page's
own heading, and 700 against a 300 h1 read as the louder of the two. */
h2 { font-size: 1.05rem; font-weight: 600; margin: 0 0 0.4rem; }
/* The back link is a block so it takes the measure and lines up with the
heading above it and the card below: auto side margins centre a block, but
they do nothing for an inline-block, which would have stayed at the column's
@@ -176,7 +296,7 @@ h2 { font-size: 1.05rem; margin: 0 0 0.4rem; }
.back { display: block; margin-bottom: 1rem; }
/* Build version, closing every authenticated page. Quiet on purpose: it is
reference material, not something to read on the way past. */
.version { margin-top: 1.6rem; text-align: right; font-size: 0.8rem; color: #6b7280; line-height: 1.45; }
.version { margin-top: 1.6rem; text-align: right; font-size: 0.8rem; color: var(--muted); line-height: 1.45; }
.version a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
.version a:hover { color: var(--fg); }
/* The signed-out column is 24rem; a right-aligned multi-link notice wraps
@@ -184,12 +304,12 @@ h2 { font-size: 1.05rem; margin: 0 0 0.4rem; }
main.page-login .version, main.page-setup .version { text-align: center; }
select, textarea {
width: 100%; padding: 0.55rem 0.7rem; font-size: 1rem;
border: 1px solid var(--control-border); border-radius: 6px; background: var(--input-bg); color: inherit;
border: 1px solid var(--control-border); border-radius: 5px; background: var(--input-bg); color: inherit;
font-family: inherit;
}
textarea { resize: vertical; }
button.danger, a.danger { background: #b42318; }
button.danger:hover, a.danger:hover { background: #912018; }
button.danger, a.danger { background: var(--danger-fill); }
button.danger:hover, a.danger:hover { background: var(--danger-fill-hover); }
/* The disclosure toggle is an action too, so it is drawn as a button (see the
compact rule below); it carries no marker because the pressed background
already shows the open state. */
@@ -204,28 +324,24 @@ button.danger:hover, a.danger:hover { background: #912018; }
.apps { list-style: none; margin: 1.2rem 0 0; padding: 0; }
.app { padding: 0.9rem 0; border-top: 1px solid var(--border); }
.app:last-child { padding-bottom: 0; }
.app-login { margin: 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-weight: 600; }
.app-login { margin: 0; font-family: var(--font-mono); font-weight: 600; }
.app-addr { margin: 0.15rem 0 0; word-break: break-all; }
.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
<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
buttons moved every time a panel opened or closed. The panels are therefore
the last children of the row, and the checkbox that opens each one is
visually hidden earlier in the row with its label drawn as the button. The
checkbox stays in the tab order and keeps its focus ring on the label, so it
works from the keyboard, and being pure CSS it also works with JavaScript
blocked, as <details> did. */
controls in half and pushed the rest below a block of fields — the buttons
moved every time a panel opened or closed. The panel is therefore the last
child of the row, and the checkbox that opens it is visually hidden earlier
in the row with its label drawn as the button. The checkbox stays in the tab
order and keeps its focus ring on the label, so it works from the keyboard,
and being pure CSS it also works with JavaScript blocked, as <details> did. */
.app .actions > .panel-toggle {
position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none;
}
.app .actions > .panel { display: none; flex: 1 0 100%; }
.app .actions > .t-mode:checked ~ .panel-mode,
.app .actions > .t-limit:checked ~ .panel-limit { display: block; }
.app .actions > .t-mode:checked ~ .for-mode,
.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 #2563eb; outline-offset: 2px; }
.app .actions > .t-edit:checked ~ .panel-edit { display: block; }
.app .actions > .t-edit:checked ~ .for-edit { background: var(--surface-open-bg); }
.app .actions > .t-edit:focus-visible ~ .for-edit { outline: 2px solid var(--accent-fill); outline-offset: 2px; }
.panel form { margin-top: 0.6rem; }
/* 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:
@@ -242,6 +358,21 @@ button.danger:hover, a.danger:hover { background: #912018; }
}
.app .actions > .panel .panel-buttons button,
.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; }
.app .actions > .panel .check-col > .check-col-title { margin-top: 0; }
.app .actions > .panel .check-col > .muted { margin: 0; }
/* Address mode select ‖ trusted-IP field: same top offset and control height. */
.app .actions > .panel .check-col > .muted + form > select:first-of-type,
.app .actions > .panel .check-col > .muted + form > textarea[name="allowed_ips"] {
margin-top: 0.45rem;
min-height: 2.55rem;
box-sizing: border-box;
}
.app .actions > .panel .check-col > .muted + form > textarea[name="allowed_ips"] {
field-sizing: fixed;
overflow-y: auto;
}
.credential { border-color: var(--credential-border); background: var(--credential-bg); }
/* Panel navigation: rendered once from the layout, so it is present on every
authenticated page without each content template having to include it. */
@@ -250,11 +381,9 @@ button.danger:hover, a.danger:hover { background: #912018; }
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
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
own sections (.sections below), which is what makes the long pages navigable.
Sticky, so both lists stay in view while the page scrolls past them. The
layout template lists the blocks in the order they are drawn, so reading and
tab order follow the eye without a CSS `order`. */
it is out of the way. Sticky, so the list stays in view while the page
scrolls past it. The layout template lists the blocks in the order they are
drawn, so reading and tab order follow the eye without a CSS `order`. */
.nav {
position: sticky; top: 2rem; align-self: flex-start;
flex: none; width: 13.5rem;
@@ -272,6 +401,9 @@ button.danger:hover, a.danger:hover { background: #912018; }
of them. It may be long and there is nothing to break it on, so it is allowed
to break mid word rather than widen the column. */
.nav .session .muted { padding: 0 0.6rem; font-size: 0.85rem; overflow-wrap: anywhere; }
.nav .session-user {
display: flex; align-items: center; gap: 0.5rem;
}
/* The mark is a link, but not one of the column's entries: it takes none of the
padding and rounding the entry rule below applies, so its own edge lines up
with the entries' icons rather than sitting half a step inside them. */
@@ -293,48 +425,30 @@ button.danger:hover, a.danger:hover { background: #912018; }
underline was what marked them as links, but a column of them reads as a
list of links already, and eight underlines down the left edge is a lot of
line for no added meaning. The hover background carries the affordance. */
/* The entries are page text, not accented links: a column of eight brick rows
would leave nothing for the current one to stand out from — that is what the
active entry's colour is for. */
.nav a, .nav [aria-current] {
display: flex; align-items: center; gap: 0.5rem;
padding: 0.35rem 0.6rem; border-radius: 6px; text-decoration: none;
padding: 0.35rem 0.6rem; border-radius: 5px; text-decoration: none;
color: var(--fg);
}
.nav a:hover { background: var(--surface-bg); }
/* The marker moved with the bar: a rule under the entry made sense in a row,
where the entries sat side by side; standing up, the current entry is marked
down its leading edge, which is the edge every entry shares. */
.nav [aria-current] {
font-weight: 600; color: var(--fg); background: var(--nav-active-bg); box-shadow: inset 2px 0 0 #2563eb;
font-weight: 600; color: var(--accent-text); background: var(--nav-active-bg);
box-shadow: inset 2px 0 0 var(--accent-fill);
}
/* Sign out carries an icon too, so it needs the same row layout; its padding
and colours come from the compact button rule further down. */
/* Sign out carries an icon too, so it needs the same row layout; its colours
come from the compact button rule further down, and its type is put back to
the column's beside it (see the rule after that one). */
.nav button { display: flex; align-items: center; gap: 0.5rem; }
/* 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. */
.nav .icon { width: 1rem; height: 1rem; flex: none; }
/* The current page's own sections, listed under the page entries. The domain
page and the status page are eight and nine cards tall, and 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 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-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
color: #6b7280;
}
/* 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: #6b7280; 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. */
/* Jumping to a card (in-page links) should not leave it touching the top edge. */
.card[id] { scroll-margin-top: 1rem; }
/* 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
@@ -358,18 +472,30 @@ button.danger:hover, a.danger:hover { background: #912018; }
.nav .brand img { width: 110px; }
/* 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. */
.nav .links, .nav .session, .nav .sections {
.nav .links, .nav .session {
flex-direction: row; flex-wrap: wrap; align-items: center;
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 page and the per-domain DNS checks, so a colour means the same thing
everywhere. The class suffix is the check's own status value. */
/* Mono and squared off rather than a bold uppercase pill: the badge carries a
value the server produced (ok, warn, delivered, bounced), and the mono face
is where the panel puts machine output everywhere else. Uppercasing it was
the pill's own emphasis — dropped with the pill, since the colour already
says how loud the badge is. */
/* line-height 1 and the padding doing the centring, rather than the 1.5 the
badge would inherit from the body. IBM Plex Mono sits a shade low in its em
square, so equal padding leaves "ok" / "warn" sunk in the box (and visibly
below the heading or label it sits next to). The bottom padding is therefore
heavier than the top — enough to lift the word to the optical centre. A
top-heavy pad was tried earlier and made the sink worse. Words with a
descender (queued) hang into that bottom pad rather than growing the badge. */
.st {
display: inline-block; padding: 0.05rem 0.45rem; border-radius: 999px;
font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
display: inline-block; padding: 0.14rem 0.45rem 0.28rem; border-radius: 4px;
font-family: var(--font-mono); font-size: 0.78rem; font-weight: 500; line-height: 1;
letter-spacing: 0.02em;
vertical-align: middle; border: 1px solid transparent;
}
.st-ok { background: var(--st-ok-bg); color: var(--st-ok-fg); border-color: var(--st-ok-border); }
@@ -395,8 +521,20 @@ meter { width: 5rem; height: 0.7rem; vertical-align: middle; margin-right: 0.4re
beside them would otherwise win the width and leave "Memory" broken across
two lines. */
.metric { white-space: nowrap; }
/* Copy sits at the top of the value — right for a long DKIM key. The row's
.code uses the compact button's vertical padding so a one-line value is the
same height as Copy and does not leave the button hanging under an empty
band of padding. */
.code-row { display: flex; align-items: flex-start; gap: 0.5rem; }
.code-row .code { flex: 1; min-width: 0; }
.code-row .code {
flex: 1; min-width: 0; margin-top: 0;
padding-top: 0.45rem; padding-bottom: 0.45rem;
}
/* A short field with its submit on the same row (Add domain). The button
keeps the card's usual filled look; its top margin is for stacked forms. */
.input-row { display: flex; align-items: stretch; gap: 0.5rem; }
.input-row input { flex: 1; min-width: 0; }
.input-row button { margin-top: 0; flex: none; white-space: nowrap; }
/* Two cards abreast, on the one page that has a pair of them worth reading
together (a delivery's facts beside its history). auto-fit with a minimum
@@ -407,12 +545,85 @@ meter { width: 5rem; height: 0.7rem; vertical-align: middle; margin-right: 0.4re
display: grid; grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr));
gap: 1.2rem; margin-top: 1.2rem;
}
/* Cards use margin: 0 auto so a lone card centres in the column. Inside the
grid those auto side margins absorb free space and shrink each card to its
content instead of filling the 1fr track — so cancel them here, and allow
the cell to shrink below the card's intrinsic minimum when the column is
tight. */
.split > .card { margin-left: 0; margin-right: 0; min-width: 0; }
/* The gap already spaces the cards inside the grid; the general .card + .card
rule would add a second gap's worth on top of it, and only to the second
card, which in one column reads as an uneven stack. The blocks around the
grid keep their spacing from it instead. */
.split > .card + .card { margin-top: 0; }
.split + .card { margin-top: 1.2rem; }
.card + .split { margin-top: 1.2rem; }
/* Configuration sits outside the polled fragment; without this it would
touch the last .split inside #status-body (a nested .split is not a sibling). */
#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; }
/* Domain settings pairs DMARC ‖ rate limit: share four row tracks so titles,
leads, fields and action buttons line up across columns. */
@supports (grid-template-rows: subgrid) {
.check-cols-rows {
grid-template-rows: auto auto 1fr auto;
}
.check-cols-rows > .check-col {
display: grid;
grid-template-rows: subgrid;
grid-row: span 4;
}
}
.check-cols-rows > .check-col > .check-col-title { margin-top: 0; }
.check-cols-rows > .check-col > .muted { margin: 0; }
.check-col-fields { min-width: 0; }
.check-col-fields > form { margin: 0; }
.check-col-fields .field-pair { margin-top: 0; }
.check-col-fields .field-pair > div > label { margin-top: 0.9rem; }
.check-col-actions {
display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem 0.75rem;
margin-top: 0;
}
.check-col-actions > button { margin-top: 1.2rem; }
.check-col-actions > form.inline { margin: 0; }
.check-col-actions > form.inline > button { margin-top: 1.2rem; }
/* 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; }
/* Hint under a field-pair sits tight above the form's submit button (the
global button margin-top would leave a blank line). */
.field-pair + p.muted { margin: 0.45rem 0 0; }
.field-pair + p.muted + button { margin-top: 0.45rem; }
/* Host / name beside a DNS Type that is always TXT — size the type column to
that token rather than giving it half the row. Vertical padding matches
.code-row .code so TXT is the same height as Host when Copy sits beside it. */
.field-pair.host-type {
grid-template-columns: minmax(0, 1fr) auto;
}
.field-pair.host-type .code {
padding-top: 0.45rem; padding-bottom: 0.45rem;
}
.field-type { width: max-content; }
.field-type .code {
width: fit-content; min-width: 2.75rem; text-align: center; box-sizing: border-box;
}
/* 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
break on. It wraps to as many lines as it needs (this is the page's name, not
@@ -428,8 +639,8 @@ h1.subject { overflow-wrap: anywhere; }
display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem;
margin-top: -0.5rem; margin-bottom: 1rem;
}
.route .addr { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9rem; overflow-wrap: anywhere; }
.route .arrow { color: #6b7280; }
.route .addr { font-family: var(--font-mono); font-size: 0.9rem; overflow-wrap: anywhere; }
.route .arrow { color: var(--muted); }
/* A card's short facts, as a grid of tiles rather than the label-over-value
stack the rest of the panel uses. That stack is right for the pages where a
value is a DNS record or a password to copy — one per line, full width — and
@@ -437,16 +648,16 @@ h1.subject { overflow-wrap: anywhere; }
would be a page of mostly empty rows. Same auto-fit as .split: as many
columns as fit, and one when nothing else does. */
.facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); gap: 0.5rem; margin-top: 1rem; }
.fact { min-width: 0; padding: 0.5rem 0.7rem; border-radius: 8px; background: var(--surface-bg); }
.fact { min-width: 0; padding: 0.5rem 0.7rem; border-radius: 6px; background: var(--surface-bg); }
.fact-label {
display: block; font-size: 0.7rem; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.04em; color: #6b7280;
display: block; font-family: var(--font-mono); font-size: 0.7rem; font-weight: 500;
text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted);
}
/* Nothing here is guaranteed to have a space in it — domains, logins and queue
ids are all one run — so a value may break mid word rather than push its tile
wider than the column it shares. */
.fact-value { display: block; margin-top: 0.1rem; overflow-wrap: anywhere; }
.fact-value.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.85rem; }
.fact-value.mono { font-family: var(--font-mono); font-size: 0.85rem; }
/* A message's history: the steps down a line, each with the time it happened,
the status it reached and what that means. The line is the list's own left
border and the dots sit on top of it, so nothing has to be positioned against
@@ -470,7 +681,7 @@ h1.subject { overflow-wrap: anywhere; }
of anything. */
.event.pending { opacity: 0.7; }
.event.pending::before { background: var(--card-bg); border-style: dashed; }
.event-time { margin: 0; font-size: 0.75rem; color: #6b7280; }
.event-time { margin: 0; font-family: var(--font-mono); font-size: 0.75rem; color: var(--muted); }
.event-title { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin: 0.15rem 0 0; font-weight: 600; }
.event-detail { margin: 0.2rem 0 0; font-size: 0.9rem; }
/* A message's own mail.log lines, as a table of when and what. The time column
@@ -486,7 +697,7 @@ table.log th:first-child, table.log td.time { width: 1%; }
while still letting a long line wrap inside the cell instead of widening it
(the th/td rule above allows the break). */
table.log td.log-text {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-family: var(--font-mono);
font-size: 0.8rem; white-space: pre-wrap;
}
/* Compact outlined button: same affordance as the filled one but quiet enough
@@ -497,31 +708,38 @@ table.log td.log-text {
the control row of an application. */
button.copy, .actions button, .actions > label.toggle, .actions a.danger, .nav button {
margin: 0; padding: 0.45rem 0.7rem; font-size: 0.8rem; font-weight: 600;
border-radius: 6px; white-space: nowrap;
background: var(--surface-bg); color: #2563eb; border: 1px solid var(--control-border);
border-radius: 5px; white-space: nowrap;
background: var(--surface-bg); color: var(--accent-text); border: 1px solid var(--control-border);
}
button.copy:hover, .actions button:hover, .actions > label.toggle:hover,
.actions a.danger:hover, .nav button:hover { background: var(--surface-bg-hover); }
button.copy { flex: none; margin-top: 0.3rem; }
button.copy { flex: none; }
.actions button.danger, .actions a.danger, .nav button.danger {
color: var(--danger-fg); background: var(--danger-bg); border-color: var(--danger-border);
}
.actions button.danger:hover, .actions a.danger:hover, .nav button.danger:hover { background: var(--danger-bg-hover); }
/* Sign out takes the compact button's colours but not its type. The rule above
is written for controls that cluster — a row of them beside a value or in a
table cell — where 0.8rem/600 is what keeps four of them from shouting. In
the navigation column it has no cluster to belong to: it stands under
Settings, among entries set 0.95rem/400, and being smaller and heavier than
every word around it made it read as a different kind of object rather than
as the last item of the list. Type and padding go back to the entries';
the red, the border and the background stay, and are what say it acts. */
.nav button {
padding: 0.35rem 0.6rem; font-size: 0.95rem; font-weight: 400;
}
/* The optional "encrypt this download" block on the backup and export forms.
Its label is the one checkbox in the panel, so it opts out of the
block-level label rule above and sits on one line with its box; the fields it
reveals are indented under it to read as its consequence rather than as three
more fields of the form. panel.js hides the inner block until the box is
ticked (and empties it when unticked); without JavaScript everything stays
visible, which the server handles identically. The import form reuses the
same indented .encrypt-fields look for its password field, but reveals it
by file extension instead of a checkbox (see panel.js). */
The checkbox uses the shared label.check rule above; the fields it reveals
are indented under it to read as its consequence rather than as three more
fields of the form. panel.js hides the inner block until the box is ticked
(and empties it when unticked); without JavaScript everything stays visible,
which the server handles identically. The import form reuses the same
indented .encrypt-fields look for its password field, but reveals it by
file extension instead of a checkbox (see panel.js). */
.encrypt { margin-top: 1.2rem; }
.encrypt label.check {
display: flex; align-items: center; gap: 0.5rem; margin: 0; font-weight: 600;
}
.encrypt label.check input { width: auto; margin: 0; }
.encrypt label.check { margin: 0; }
.encrypt-fields {
margin-top: 1.2rem; margin-left: 1.6rem; padding-left: 0.9rem;
border-left: 2px solid var(--border);
+46 -63
View File
@@ -72,6 +72,50 @@
});
}
// --- 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 ------------------
// Global administrators manage every domain, so the assignment checkboxes
// are irrelevant for that role. The toggle runs on load too, because the
// edit form of an existing global user should not flash the fieldset.
function syncDomainPickField(select) {
var form = select.closest("form");
var field = form && form.querySelector("[data-domain-pick]");
if (!field) {
return;
}
field.hidden = select.value === select.dataset.globalRole;
}
function initDomainPickFields(root) {
root.querySelectorAll("select[data-global-role]").forEach(function (select) {
syncDomainPickField(select);
select.addEventListener("change", function () {
syncDomainPickField(select);
});
});
}
// --- Encryption password fields shown only when asked for --------------
// The backup, export and import forms carry an optional password block. It
// is hidden until the checkbox next to it is ticked, and cleared when it is
@@ -135,73 +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 () {
initAddressFields(document);
initCustomAddressFields(document);
initDomainPickFields(document);
initEncryptFields(document);
initImportPasswordField(document);
initSectionIndex();
});
// --- Adaptive monitoring polling ---------------------------------------