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:
Binary file not shown.
Binary file not shown.
Binary file not shown.
+347
-129
@@ -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);
|
||||
|
||||
@@ -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 ---------------------------------------
|
||||
|
||||
@@ -21,7 +21,12 @@ func serveStatic(path string, headers map[string]string) *httptest.ResponseRecor
|
||||
// prompted this: a browser given no ETag and no Last-Modified caches it on a
|
||||
// guess, and a rebranded panel keeps serving the old mark from the tab.
|
||||
func TestStaticAssetsCarryETag(t *testing.T) {
|
||||
for _, name := range []string{"favicon.png", "favicon.svg", "panel.css", "panel.js", "htmx.min.js"} {
|
||||
for _, name := range []string{
|
||||
"favicon.png", "favicon.svg", "panel.css", "panel.js", "htmx.min.js",
|
||||
// The fonts are the assets this matters most for: they are the largest
|
||||
// thing the panel serves and the ones a browser is most willing to keep.
|
||||
"ibm-plex-sans.woff2", "ibm-plex-mono-400.woff2", "ibm-plex-mono-600.woff2",
|
||||
} {
|
||||
rec := serveStatic("/static/"+name, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s: got status %d, want 200", name, rec.Code)
|
||||
|
||||
@@ -3,19 +3,21 @@
|
||||
|
||||
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||
|
||||
{{if .IsGlobal}}
|
||||
<div class="card">
|
||||
<h2>Add a sending domain</h2>
|
||||
<form method="post" action="/domains">
|
||||
<label for="name">Domain</label>
|
||||
<input id="name" name="name" type="text" placeholder="example.com"
|
||||
autocomplete="off" autocapitalize="none" spellcheck="false"
|
||||
value="{{.FormName}}" autofocus required>
|
||||
<div class="input-row">
|
||||
<input id="name" name="name" type="text" placeholder="example.com"
|
||||
autocomplete="off" autocapitalize="none" spellcheck="false"
|
||||
value="{{.FormName}}" autofocus required>
|
||||
<button type="submit">Add domain</button>
|
||||
</div>
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
<button type="submit">Add domain</button>
|
||||
</form>
|
||||
<p class="muted">A DKIM key is generated for the domain; you then publish the
|
||||
shown DNS record. Adding a domain does not create an application.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="card">
|
||||
<h2>Domains</h2>
|
||||
@@ -31,7 +33,7 @@
|
||||
<td><a class="st st-{{.DNS}}" href="/domains/{{.ID}}#dns-status">{{.DNS}}</a></td>
|
||||
<td class="muted">{{.DKIMSelector}}</td>
|
||||
<td>{{.AppCount}}</td>
|
||||
<td class="actions"><a class="danger" href="/domains/{{.ID}}/delete">Delete</a></td>
|
||||
{{if $.IsGlobal}}<td class="actions"><a class="danger" href="/domains/{{.ID}}/delete">Delete</a></td>{{else}}<td></td>{{end}}
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<td>{{.From}}</td>
|
||||
<td>{{.To}}</td>
|
||||
<td class="subject"><span title="{{.Subject}}">{{.Subject}}</span></td>
|
||||
<td>{{.Status}}</td>
|
||||
<td class="status"><span class="st st-{{.Level}}">{{.Status}}</span></td>
|
||||
<td class="actions"><a href="/deliveries/{{.ID}}?domain={{$.FilterDomain}}&app={{$.FilterApp}}&p={{$.Page}}">Details</a></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<span class="st st-{{.Level}}">{{.Row.Status}}</span>
|
||||
</p>
|
||||
|
||||
<a class="back" href="{{.BackURL}}">← Back to deliveries</a>
|
||||
{{template "back_link" (back .BackURL "Back to deliveries")}}
|
||||
|
||||
{{/* The two columns: what was recorded on the left, in what order it happened
|
||||
on the right. They are a pair — the facts are only worth reading against
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{{define "content"}}
|
||||
<h1>Delete {{.Domain.Name}}</h1>
|
||||
|
||||
<a class="back" href="/domains/{{.Domain.ID}}">← Back to {{.Domain.Name}}</a>
|
||||
{{template "back_link" (back (printf "/domains/%d" .Domain.ID) (printf "Back to %s" .Domain.Name))}}
|
||||
|
||||
<div class="card">
|
||||
<h2>Confirm deletion</h2>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
{{/* Wide so the .split pairs fill the column rather than the 48rem reading
|
||||
measure (same pattern as Status). */}}
|
||||
{{define "wide"}}wide{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<h1>{{.Domain.Name}}</h1>
|
||||
|
||||
<a class="back" href="/domains">← All domains</a>
|
||||
{{template "back_link" (back "/domains" "All domains")}}
|
||||
|
||||
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||
{{if .RateLimitErr}}<div class="flash error">{{.RateLimitErr}}</div>{{end}}
|
||||
@@ -9,8 +13,8 @@
|
||||
{{if .NewCred}}
|
||||
<div class="card credential" id="new-credential">
|
||||
<h2>New application password</h2>
|
||||
<p class="muted">This password is shown <strong>once only</strong> and is not
|
||||
stored. Copy it now — if it is lost, regenerate a new one.</p>
|
||||
<p class="muted">Shown <strong>once only</strong> and not stored. Copy it now
|
||||
— if it is lost, regenerate a new one.</p>
|
||||
<label>Login</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.NewCred.Login}}</span>
|
||||
@@ -24,202 +28,279 @@
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="card" id="dkim">
|
||||
<h2>DKIM DNS record</h2>
|
||||
<p class="muted">Publish this TXT record in the DNS for <strong>{{.Domain.Name}}</strong>.
|
||||
It is not a secret and can be viewed at any time.</p>
|
||||
|
||||
<label>Host / name</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.Record.Name}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
|
||||
<label>Type</label>
|
||||
<span class="code">TXT</span>
|
||||
|
||||
<label>Value</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.Record.Value}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
|
||||
<p class="muted">Mail is signed with selector <strong>{{.Domain.DKIMSelector}}</strong>.</p>
|
||||
</div>
|
||||
|
||||
<div class="card" id="spf-dmarc">
|
||||
<h2>SPF and DMARC records</h2>
|
||||
<p class="muted">These two are not generated the way the DKIM record above is —
|
||||
they are policy, and the domain may already publish an SPF record for other
|
||||
senders. SelfPost is a send-only relay — most operators have no inbox on the
|
||||
sending domain, so the suggested DMARC record omits <code>rua=</code> unless a
|
||||
report address is configured below or in <a href="/account">Settings</a>.
|
||||
Publish both as TXT records.</p>
|
||||
|
||||
<label>SPF — host / name</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.Domain.Name}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
|
||||
<label>SPF — value</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.SPFExample}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
|
||||
<p class="muted">A domain may have only one SPF record. If it already has one,
|
||||
do not add a second — add this server's mechanism to the existing record
|
||||
instead, before its <code>all</code> term.</p>
|
||||
|
||||
<form method="post" action="/domains/{{.Domain.ID}}/dmarc">
|
||||
<label for="dmarc_rua_mode">DMARC 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>
|
||||
|
||||
<label>DMARC — host / name</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.DMARCName}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
|
||||
<label>DMARC — 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">
|
||||
<span class="code">{{.DMARCExample}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
|
||||
{{if .SameDomainRUA}}
|
||||
<p class="error">The report address is on this sending domain. SelfPost does
|
||||
not receive inbound mail — use a mailbox elsewhere or wait for in-panel report
|
||||
reception in a future release.</p>
|
||||
{{end}}
|
||||
|
||||
{{if .NeedsReportAuth}}
|
||||
<label>Report authorization — host / name</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.ReportAuthName}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
|
||||
<label>Report authorization — value</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.ReportAuthValue}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<p class="muted"><code>p=none</code> changes nothing about delivery. Omit
|
||||
<code>rua=</code> on a send-only domain, or point it at a mailbox that
|
||||
receives inbound mail. Tighten to <code>p=quarantine</code> and then
|
||||
<code>p=reject</code> once aggregate reports look clean.</p>
|
||||
</div>
|
||||
|
||||
{{/* 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">What DNS publishes for <strong>{{.Domain.Name}}</strong> right
|
||||
now, checked against the key this server signs with. Results are cached for a
|
||||
few minutes — after publishing a record, use <em>Re-check</em>.</p>
|
||||
<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}}
|
||||
<div class="check-cols">
|
||||
<div class="check-col">
|
||||
<label>DKIM <span class="st st-{{.DNS.DKIM.Status}}">{{.DNS.DKIM.Status}}</span></label>
|
||||
<div class="field-pair host-type">
|
||||
<div>
|
||||
<label>Host / name</label>
|
||||
<span class="code">{{.Record.Name}}</span>
|
||||
</div>
|
||||
<div class="field-type">
|
||||
<label>Type</label>
|
||||
<span class="code">TXT</span>
|
||||
</div>
|
||||
</div>
|
||||
{{if .DNS.DKIM.Records}}
|
||||
<label>Value</label>
|
||||
<span class="code">{{range .DNS.DKIM.Records}}{{.}}
|
||||
{{end}}</span>
|
||||
{{end}}
|
||||
{{if ne .DNS.DKIM.Status "ok"}}
|
||||
<p class="{{if eq .DNS.DKIM.Status "unknown"}}muted{{else}}error{{end}}">{{.DNS.DKIM.Detail}}</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<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">The SPF check is deliberately shallow: it looks for a
|
||||
mechanism that literally covers this server's address and does not follow
|
||||
<code>include:</code> or <code>redirect=</code>, so a record that authorises
|
||||
the server through an include is reported as “cannot tell”, not as a failure.</p>
|
||||
<div class="check-col">
|
||||
<label>SPF <span class="st st-{{.DNS.SPF.Status}}">{{.DNS.SPF.Status}}</span></label>
|
||||
<div class="field-pair host-type">
|
||||
<div>
|
||||
<label>Host / name</label>
|
||||
<span class="code">{{.Domain.Name}}</span>
|
||||
</div>
|
||||
<div class="field-type">
|
||||
<label>Type</label>
|
||||
<span class="code">TXT</span>
|
||||
</div>
|
||||
</div>
|
||||
{{if .DNS.SPF.Records}}
|
||||
<label>Value</label>
|
||||
<span class="code">{{range .DNS.SPF.Records}}{{.}}
|
||||
{{end}}</span>
|
||||
{{end}}
|
||||
{{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>
|
||||
</div>
|
||||
|
||||
<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 class="check-col">
|
||||
<label>DMARC <span class="st st-{{.DNS.DMARC.Status}}">{{.DNS.DMARC.Status}}</span></label>
|
||||
<div class="field-pair host-type">
|
||||
<div>
|
||||
<label>Host / name</label>
|
||||
<span class="code">{{.DMARCName}}</span>
|
||||
</div>
|
||||
<div class="field-type">
|
||||
<label>Type</label>
|
||||
<span class="code">TXT</span>
|
||||
</div>
|
||||
</div>
|
||||
{{if .DNS.DMARC.Records}}
|
||||
<label>Value</label>
|
||||
<span class="code">{{range .DNS.DMARC.Records}}{{.}}
|
||||
{{end}}</span>
|
||||
{{end}}
|
||||
{{if ne .DNS.DMARC.Status "ok"}}
|
||||
<p class="{{if eq .DNS.DMARC.Status "unknown"}}muted{{else}}error{{end}}">{{.DNS.DMARC.Detail}}</p>
|
||||
{{else}}
|
||||
<p class="muted">{{.DNS.DMARC.Detail}}</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{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}}
|
||||
<div class="check-col">
|
||||
{{if .DNS.DMARCReportAuth.Status}}
|
||||
<label>Report authorization <span class="st st-{{.DNS.DMARCReportAuth.Status}}">{{.DNS.DMARCReportAuth.Status}}</span></label>
|
||||
<div class="field-pair host-type">
|
||||
<div>
|
||||
<label>Host / name</label>
|
||||
<span class="code">{{.ReportAuthName}}</span>
|
||||
</div>
|
||||
<div class="field-type">
|
||||
<label>Type</label>
|
||||
<span class="code">TXT</span>
|
||||
</div>
|
||||
</div>
|
||||
{{if .DNS.DMARCReportAuth.Records}}
|
||||
<label>Value</label>
|
||||
<span class="code">{{range .DNS.DMARCReportAuth.Records}}{{.}}
|
||||
{{end}}</span>
|
||||
{{end}}
|
||||
<p class="{{if eq .DNS.DMARCReportAuth.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DMARCReportAuth.Detail}}</p>
|
||||
{{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="card" id="settings">
|
||||
<h2>Sending server settings</h2>
|
||||
<p class="muted">Point the mail client or script at these settings and
|
||||
authenticate with an application login and password from the
|
||||
<strong>Applications</strong> section below. They are the same for every
|
||||
domain on this server.</p>
|
||||
<div class="split">
|
||||
<div class="card" id="dkim-spf">
|
||||
<h2>DKIM and SPF records</h2>
|
||||
|
||||
<label>Server</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.Hostname}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
|
||||
<label>Port and encryption</label>
|
||||
<span class="code">465 — SSL/TLS (implicit){{if .SubmissionEnabled}}
|
||||
587 — STARTTLS (submission){{end}}</span>
|
||||
|
||||
<p class="muted">Authentication is required on every port. The username is the
|
||||
application's login (see the table below) and the password is the one shown
|
||||
once when that application was created or its password regenerated — if it was
|
||||
lost, generate a new one.</p>
|
||||
</div>
|
||||
|
||||
{{/* Create form above the list, the same order the domains page uses for
|
||||
"Add a sending domain" above "Domains". */}}
|
||||
<div class="card" id="add-application">
|
||||
<h2>Add an application</h2>
|
||||
<form method="post" action="/domains/{{.Domain.ID}}/applications">
|
||||
<label for="login">Login</label>
|
||||
<input id="login" name="login" type="text" placeholder="prod-server"
|
||||
autocomplete="off" autocapitalize="none" spellcheck="false"
|
||||
value="{{.FormLogin}}" required>
|
||||
|
||||
<label for="mode">Address mode</label>
|
||||
<select id="mode" name="mode" data-list-mode="{{.List}}">
|
||||
<option value="{{.Wildcard}}" {{if eq .FormMode .Wildcard}}selected{{end}}>Any address of the domain</option>
|
||||
<option value="{{.List}}" {{if eq .FormMode .List}}selected{{end}}>Specific addresses (list)</option>
|
||||
</select>
|
||||
|
||||
<div data-addresses>
|
||||
<label for="addresses">Addresses (one per line or comma-separated)</label>
|
||||
<textarea id="addresses" name="addresses" rows="3"
|
||||
placeholder="alerts@{{.Domain.Name}}">{{.FormAddrs}}</textarea>
|
||||
<p class="check-col-title">DKIM</p>
|
||||
<div class="field-pair host-type">
|
||||
<div>
|
||||
<label>Host / name</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.Record.Name}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-type">
|
||||
<label>Type</label>
|
||||
<span class="code">TXT</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
<button type="submit">Create application</button>
|
||||
</form>
|
||||
<p class="muted">A strong password is generated and shown once. The login must
|
||||
be unique across all domains and may contain letters, digits, '.', '-' and '_'.</p>
|
||||
<label>Value</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.Record.Value}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
|
||||
<p class="muted">Not a secret. Signed with selector
|
||||
<strong>{{.Domain.DKIMSelector}}</strong>.</p>
|
||||
|
||||
<p class="check-col-title">SPF</p>
|
||||
<div class="field-pair host-type">
|
||||
<div>
|
||||
<label>Host / name</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.Domain.Name}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-type">
|
||||
<label>Type</label>
|
||||
<span class="code">TXT</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label>Value</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.SPFExample}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
|
||||
<p class="muted">Merge into an existing SPF if the domain already has one —
|
||||
do not publish a second record.</p>
|
||||
</div>
|
||||
|
||||
<div class="card" id="dmarc">
|
||||
<h2>DMARC record</h2>
|
||||
|
||||
<div class="field-pair host-type">
|
||||
<div>
|
||||
<label>Host / name</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.DMARCName}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-type">
|
||||
<label>Type</label>
|
||||
<span class="code">TXT</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<span class="code">{{.DMARCExample}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
|
||||
{{if .SameDomainRUA}}
|
||||
<p class="error">The report address is on this sending domain. SelfPost does
|
||||
not receive inbound mail — use a mailbox elsewhere or wait for in-panel report
|
||||
reception in a future release.</p>
|
||||
{{end}}
|
||||
|
||||
{{if .NeedsReportAuth}}
|
||||
<p class="check-col-title">Report authorization</p>
|
||||
<div class="field-pair host-type">
|
||||
<div>
|
||||
<label>Host / name</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.ReportAuthName}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-type">
|
||||
<label>Type</label>
|
||||
<span class="code">TXT</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label>Value</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.ReportAuthValue}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<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.
|
||||
Report address is set under <a href="#domain-settings">Domain settings</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="split">
|
||||
<div class="card" id="connection">
|
||||
<h2>Connection settings</h2>
|
||||
<p class="muted">Same for every domain. Authenticate with an application
|
||||
login from below.</p>
|
||||
|
||||
<label>Server</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.Hostname}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
|
||||
<label>Port and encryption</label>
|
||||
<span class="code">465 — SSL/TLS (implicit){{if .SubmissionEnabled}}
|
||||
587 — STARTTLS (submission){{end}}</span>
|
||||
|
||||
<p class="muted">Auth required on every port. The password is shown once at
|
||||
create or regenerate.</p>
|
||||
</div>
|
||||
|
||||
{{/* Create form beside connection settings, mirroring "Add a sending domain"
|
||||
above the domains list. */}}
|
||||
<div class="card" id="add-application">
|
||||
<h2>Add an application</h2>
|
||||
<form method="post" action="/domains/{{.Domain.ID}}/applications">
|
||||
<label for="login">Login</label>
|
||||
<input id="login" name="login" type="text" placeholder="prod-server"
|
||||
autocomplete="off" autocapitalize="none" spellcheck="false"
|
||||
value="{{.FormLogin}}" required>
|
||||
|
||||
<label for="mode">Address mode</label>
|
||||
<select id="mode" name="mode" data-list-mode="{{.List}}">
|
||||
<option value="{{.Wildcard}}" {{if eq .FormMode .Wildcard}}selected{{end}}>Any address of the domain</option>
|
||||
<option value="{{.List}}" {{if eq .FormMode .List}}selected{{end}}>Specific addresses (list)</option>
|
||||
</select>
|
||||
|
||||
<div data-addresses>
|
||||
<label for="addresses">Addresses (one per line or comma-separated)</label>
|
||||
<textarea id="addresses" name="addresses" rows="3"
|
||||
placeholder="alerts@{{.Domain.Name}}">{{.FormAddrs}}</textarea>
|
||||
</div>
|
||||
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
<button type="submit">Create application</button>
|
||||
</form>
|
||||
<p class="muted">Password shown once. Login unique across domains; letters,
|
||||
digits, '.', '-' and '_'.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="applications">
|
||||
<h2>Applications</h2>
|
||||
<p class="muted">Each application is a SASL login/password an app or script
|
||||
uses to send mail as this domain. A login may send from any address of the
|
||||
domain (<em>wildcard</em>) or only from a fixed list of addresses.</p>
|
||||
<p class="muted">SASL logins for this domain — wildcard (*@domain) or a fixed
|
||||
address list.</p>
|
||||
|
||||
{{if .Apps}}
|
||||
<ul class="apps">
|
||||
@@ -230,17 +311,12 @@
|
||||
{{if eq .AddressMode $.Wildcard}}Any address of the domain — *@{{$.Domain.Name}}
|
||||
{{else}}Fixed list — {{range $i, $a := .Addresses}}{{if $i}}, {{end}}{{$a}}{{end}}{{end}}
|
||||
</p>
|
||||
<!-- The two panels are opened by a checkbox and a label rather than by
|
||||
<details>, so that the controls stay a single row of buttons and
|
||||
what a panel reveals is laid out under all four of them instead of
|
||||
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. -->
|
||||
<!-- One Edit panel (mode ‖ rate limit) opened by a checkbox and label
|
||||
rather than <details>, so the button row stays intact — see
|
||||
.panel-toggle in panel.css. -->
|
||||
<div class="actions">
|
||||
<input class="panel-toggle t-mode" id="mode-{{.ID}}" type="checkbox">
|
||||
<label class="toggle for-mode" for="mode-{{.ID}}">Edit mode</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>
|
||||
<input class="panel-toggle t-edit" id="edit-{{.ID}}" type="checkbox">
|
||||
<label class="toggle for-edit" for="edit-{{.ID}}">Edit{{if .HasLimit}} <span class="st st-ok">limit</span>{{end}}</label>
|
||||
<form class="inline" method="post" action="/applications/{{.ID}}/password"
|
||||
data-confirm="Regenerate the password for {{.Login}}? The current password stops working immediately.">
|
||||
<button type="submit">New password</button>
|
||||
@@ -249,44 +325,60 @@
|
||||
data-confirm="Delete application {{.Login}}? Its credentials stop working immediately.">
|
||||
<button type="submit" class="danger">Delete</button>
|
||||
</form>
|
||||
<div class="panel panel-mode">
|
||||
<form method="post" action="/applications/{{.ID}}/mode">
|
||||
<label>Address mode</label>
|
||||
<select name="mode" data-list-mode="{{$.List}}">
|
||||
<option value="{{$.Wildcard}}" {{if eq .AddressMode $.Wildcard}}selected{{end}}>Any address of the domain</option>
|
||||
<option value="{{$.List}}" {{if eq .AddressMode $.List}}selected{{end}}>Specific addresses (list)</option>
|
||||
</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}}
|
||||
<div class="panel panel-edit">
|
||||
<div class="check-cols">
|
||||
<div class="check-col">
|
||||
<p class="check-col-title">Address mode</p>
|
||||
<p class="muted">Which From addresses this application may use.</p>
|
||||
<form method="post" action="/applications/{{.ID}}/mode">
|
||||
<select name="mode" data-list-mode="{{$.List}}" aria-label="Address mode">
|
||||
<option value="{{$.Wildcard}}" {{if eq .AddressMode $.Wildcard}}selected{{end}}>Any address of the domain</option>
|
||||
<option value="{{$.List}}" {{if eq .AddressMode $.List}}selected{{end}}>Specific addresses (list)</option>
|
||||
</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>
|
||||
</div>
|
||||
<button type="submit">Save mode</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="check-col">
|
||||
<p class="check-col-title">Optional trusted-IP override
|
||||
{{if .HasLimit}}<span class="st st-ok">active</span>{{else}}<span class="st st-unknown">inactive</span>{{end}}</p>
|
||||
<p class="muted">One per line or comma-separated.</p>
|
||||
<form id="rl-{{.ID}}" method="post" action="/applications/{{.ID}}/ratelimit">
|
||||
<textarea name="allowed_ips" rows="1" placeholder="203.0.113.10"
|
||||
aria-label="Trusted client IPs">{{.IPsText}}</textarea>
|
||||
<p class="muted">These IPs get a higher ceiling than the domain
|
||||
(≤ level 1) and skip the domain check; everyone else uses
|
||||
the domain level-2 limit{{if $.DomainHasRL}} ({{$.DomainRLMaxNum}}){{end}}
|
||||
if set, otherwise level 1.</p>
|
||||
<div class="field-pair">
|
||||
<div>
|
||||
<label>Message limit (max {{$.L1Messages}})</label>
|
||||
<input name="max_messages" type="number" min="1" max="{{$.L1Messages}}"
|
||||
value="{{.MaxText}}" placeholder="{{$.L1Messages}}">
|
||||
</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}}? The domain limit (or level 1) will apply.">
|
||||
<input type="hidden" name="clear" value="1">
|
||||
<button type="submit" class="danger">Remove limit</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</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>
|
||||
@@ -298,84 +390,87 @@
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card" id="rate-limit">
|
||||
<h2>Sending rate limit (domain)</h2>
|
||||
<p class="muted">Optional level-2 limit (spec 7.4): cap how many messages this
|
||||
domain may send from its expected client IP(s) within a time window, summed
|
||||
across all its applications. It counts messages — one message to many
|
||||
recipients counts once. Leave the IP list empty to disable it and rely only on
|
||||
the global level-1 limit. Applications that send from changing IPs should be
|
||||
left unbound here.</p>
|
||||
<div class="card" id="domain-settings">
|
||||
<h2>Domain settings</h2>
|
||||
|
||||
<p class="muted">Status:
|
||||
{{if .DomainHasRL}}<strong>active</strong>{{else}}inactive (level-1 only){{end}}.</p>
|
||||
<div class="check-cols check-cols-rows">
|
||||
<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>
|
||||
<div class="check-col-fields">
|
||||
<form id="dmarc-domain" 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">
|
||||
<label for="d_ips">Expected client IPs (one per line or comma-separated)</label>
|
||||
<textarea id="d_ips" name="allowed_ips" rows="2"
|
||||
placeholder="203.0.113.10">{{.DomainRLIPs}}</textarea>
|
||||
<div data-custom-address>
|
||||
<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">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="check-col-actions">
|
||||
<button type="submit" form="dmarc-domain">Save DMARC report settings</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label for="d_max">Message limit</label>
|
||||
<input id="d_max" name="max_messages" type="number" min="1"
|
||||
value="{{.DomainRLMax}}" placeholder="1000">
|
||||
|
||||
<label for="d_win">Window (seconds)</label>
|
||||
<input id="d_win" name="window_seconds" type="number" min="1" value="{{.DomainRLWin}}">
|
||||
|
||||
<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 class="check-col">
|
||||
<p class="check-col-title">Level-2 rate limit
|
||||
{{if .DomainHasRL}}<span class="st st-ok">active</span>{{else}}<span class="st st-unknown">inactive</span>{{end}}</p>
|
||||
<p class="muted">Level 1 backstop: {{.L1Messages}} messages / {{.L1Window}}s — <a href="/settings#rate-limits">Settings</a>.</p>
|
||||
<div class="check-col-fields">
|
||||
<form id="rl-domain" method="post" action="/domains/{{.Domain.ID}}/ratelimit">
|
||||
<div class="field-pair">
|
||||
<div>
|
||||
<label for="d_max">Message limit (max {{.L1Messages}})</label>
|
||||
<input id="d_max" name="max_messages" type="number" min="1" max="{{.L1Messages}}"
|
||||
value="{{.DomainRLMax}}" placeholder="{{.L1Messages}}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="d_win">Window (seconds)</label>
|
||||
<input id="d_win" name="window_seconds" type="number" min="1" value="{{.DomainRLWin}}">
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted">Applies to every client IP on this domain. Leave the
|
||||
message limit empty to use level 1 only.</p>
|
||||
</form>
|
||||
</div>
|
||||
<div class="check-col-actions">
|
||||
<button type="submit" form="rl-domain">Save limit</button>
|
||||
{{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="card" id="export">
|
||||
<h2>Export domain</h2>
|
||||
<p class="muted">Download this domain to move it to another SelfPost instance:
|
||||
its DKIM key, selector and every application with its working password. On
|
||||
import the DNS record stays the same, so no DNS change is needed.</p>
|
||||
<p class="muted"><strong>The export file is a secret</strong> — it contains the
|
||||
private DKIM key and application passwords. Transfer it securely and delete it
|
||||
after the import, or encrypt it below and move a <code>.spde</code> file
|
||||
(SelfPost domain export) instead; the import form asks for the password.</p>
|
||||
{{if .ExportErr}}<p class="error">{{.ExportErr}}</p>{{end}}
|
||||
<form method="post" action="/domains/{{.Domain.ID}}/export">
|
||||
{{template "encryptfields" .}}
|
||||
<button type="submit">Export domain</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="split">
|
||||
<div class="card" id="export">
|
||||
<h2>Export domain</h2>
|
||||
<p class="muted"><strong>Secret file</strong> — transfer securely, or encrypt
|
||||
below as <code>.spde</code>.</p>
|
||||
{{if .ExportErr}}<p class="error">{{.ExportErr}}</p>{{end}}
|
||||
<form method="post" action="/domains/{{.Domain.ID}}/export">
|
||||
{{template "encryptfields" .}}
|
||||
<button type="submit">Export domain</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card" id="danger">
|
||||
<h2>Danger zone</h2>
|
||||
<p class="muted">Deleting this domain also deletes its DKIM key and every
|
||||
application bound to it.</p>
|
||||
<a class="danger" href="/domains/{{.Domain.ID}}/delete">Delete domain</a>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* The domain page's section index, shown in the navigation column (see the
|
||||
"sections" block in layout.html). This is the panel's longest page — the
|
||||
DNS records to publish, the checks on them, the applications and two rate
|
||||
limits — and setting a domain up means going back and forth between them.
|
||||
The freshly generated password is only listed while it is on the page: it
|
||||
is the one card that is not always there, and the one nothing should scroll
|
||||
away from silently. */}}
|
||||
{{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="#spf-dmarc">SPF and DMARC records</a>
|
||||
<a href="#dns-status">DNS status</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 class="card" id="danger">
|
||||
<h2>Danger zone</h2>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -61,22 +61,26 @@
|
||||
{{define "nav"}}
|
||||
<nav class="nav">
|
||||
{{/* 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,
|
||||
and the session last. The mark goes to /status, the page the panel opens
|
||||
on — named outright rather than as "/", which is only a redirect to it. */}}
|
||||
<a class="brand" href="/status"><img src="/static/logo-compact.svg" width="220" height="100" alt="SelfPost"></a>
|
||||
the column: the mark, the panel's pages, and the session last. The mark
|
||||
goes to /status, the page the panel opens on — named outright rather
|
||||
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>
|
||||
<div class="links">
|
||||
{{if .IsGlobal}}
|
||||
{{if eq .Active "status"}}<span aria-current="page">{{template "icon-status"}}Status</span>{{else}}<a href="/status">{{template "icon-status"}}Status</a>{{end}}
|
||||
{{end}}
|
||||
{{if eq .Active "domains"}}<span aria-current="page">{{template "icon-domains"}}Domains</span>{{else}}<a href="/domains">{{template "icon-domains"}}Domains</a>{{end}}
|
||||
{{if eq .Active "deliveries"}}<span aria-current="page">{{template "icon-deliveries"}}Deliveries</span>{{else}}<a href="/deliveries">{{template "icon-deliveries"}}Deliveries</a>{{end}}
|
||||
{{if .IsGlobal}}
|
||||
{{if eq .Active "mail_queue"}}<span aria-current="page">{{template "icon-mail-queue"}}Mail queue</span>{{else}}<a href="/mail-queue">{{template "icon-mail-queue"}}Mail queue</a>{{end}}
|
||||
{{if eq .Active "system_log"}}<span aria-current="page">{{template "icon-system-log"}}System log</span>{{else}}<a href="/system-log">{{template "icon-system-log"}}System log</a>{{end}}
|
||||
{{if eq .Active "backup"}}<span aria-current="page">{{template "icon-backup"}}Backup</span>{{else}}<a href="/backup">{{template "icon-backup"}}Backup</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}}
|
||||
</div>
|
||||
{{template "sections" .}}
|
||||
<div class="session">
|
||||
<span class="muted">User: {{.User}}</span>
|
||||
{{if eq .Active "account"}}<span aria-current="page">{{template "icon-account"}}Settings</span>{{else}}<a href="/account">{{template "icon-account"}}Settings</a>{{end}}
|
||||
<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}}
|
||||
<form class="inline" method="post" action="/logout">
|
||||
<button type="submit" class="danger">{{template "icon-sign-out"}}Sign out</button>
|
||||
</form>
|
||||
@@ -84,24 +88,22 @@
|
||||
</nav>
|
||||
{{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
|
||||
the reading measure unless it says otherwise; the three pages that are
|
||||
tables of data or raw log lines redefine this as the word "wide", which
|
||||
lands in <main>'s class list (see main.wide in panel.css). Same mechanism
|
||||
as "sections" above: each page's template file is parsed after this one,
|
||||
so its definition replaces the empty one. */}}
|
||||
the reading measure unless it says otherwise; the pages that are tables of
|
||||
data, raw log lines or side-by-side cards redefine this as the word "wide",
|
||||
which lands in <main>'s class list (see main.wide in panel.css). Each
|
||||
page's template file is parsed after this one, so its definition replaces
|
||||
the empty one. */}}
|
||||
{{define "wide"}}{{end}}
|
||||
|
||||
{{/* back_link — up-navigation on drill-down pages. Invoke with the "back"
|
||||
function, e.g. {{template "back_link" (back "/users" "Back to users")}}.
|
||||
Place it directly under the page <h1> and above the cards (see .back in
|
||||
panel.css). TestDrillDownPagesPlaceBackLinkAboveContent guards this. */}}
|
||||
{{define "back_link"}}
|
||||
<a class="back" href="{{.Href}}">← {{.Label}}</a>
|
||||
{{end}}
|
||||
|
||||
{{/* Navigation icons. Inline SVG rather than an icon font or sprite file: they
|
||||
inherit the link's colour through currentColor, cost no extra request, and
|
||||
need no exemption from the panel's "default-src 'self'" policy. Each is
|
||||
@@ -117,6 +119,15 @@
|
||||
two drawers with handles is the picture of an archive. */}}
|
||||
{{define "icon-backup"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2.75" y="1.75" width="10.5" height="12.5" rx="1.15"/><path d="M2.75 8h10.5"/><path d="M6.4 4.85h3.2M6.4 11.15h3.2"/></svg>{{end}}
|
||||
{{define "icon-account"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="8" cy="5.4" r="2.75"/><path d="M2.9 14.25a5.1 5.1 0 0 1 10.2 0"/></svg>{{end}}
|
||||
{{/* Two people: rear as a right-side crescent (head + shoulder) so it peeks
|
||||
from behind; front is a full silhouette matching icon-account. Heads share
|
||||
one baseline; shoulder arcs share one floor — otherwise the pair looks
|
||||
lopsided at 16 px. */}}
|
||||
{{define "icon-users"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.9 3.1a2.1 2.1 0 0 1 0 4.2"/><path d="M14.7 14.25a3.8 3.8 0 0 0-3.9-3.65"/><circle cx="5.5" cy="5.2" r="2.5"/><path d="M1.4 14.25a4.8 4.8 0 0 1 8.2 0"/></svg>{{end}}
|
||||
{{/* A gear (Feather "settings"), not a sun-with-rays: at 16 px spokes alone
|
||||
read as brightness, while the toothed rim is the picture of configuration.
|
||||
viewBox 0 0 24 matches the source path; CSS sizes the icon to 1 rem. */}}
|
||||
{{define "icon-settings"}}<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>{{end}}
|
||||
{{/* Sign out is the one icon here that marks an action rather than a page: an
|
||||
open door with the arrow leading out of it. */}}
|
||||
{{define "icon-sign-out"}}<svg class="icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.1 14.25H3.65a1.15 1.15 0 0 1-1.15-1.15V2.9a1.15 1.15 0 0 1 1.15-1.15H6.1"/><path d="M10.6 11.15 13.75 8 10.6 4.85"/><path d="M13.75 8H6.35"/></svg>{{end}}
|
||||
|
||||
+67
-8
@@ -1,14 +1,21 @@
|
||||
{{/* Wide enough for credentials and DMARC settings side by side (see .split
|
||||
in panel.css, as on a delivery's page). */}}
|
||||
{{define "wide"}}wide{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<h1>Settings</h1>
|
||||
|
||||
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
|
||||
{{if .ShowDMARC}}
|
||||
<form method="post" action="/settings">
|
||||
<div class="split">
|
||||
<div class="card">
|
||||
<h2>Panel credentials</h2>
|
||||
<p class="muted">These are the credentials for this control panel only.
|
||||
Applications keep their own logins and passwords, which are not affected.</p>
|
||||
|
||||
<div class="card narrow">
|
||||
<h2>Panel credentials</h2>
|
||||
<p class="muted">These are the credentials for this control panel only.
|
||||
Applications keep their own logins and passwords, which are not affected.</p>
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
<form method="post" action="/account">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" name="username" autocomplete="username"
|
||||
autocapitalize="none" spellcheck="false" value="{{.FormUsername}}" required>
|
||||
@@ -22,8 +29,10 @@
|
||||
|
||||
<label for="new_password_confirm">Confirm new password</label>
|
||||
<input id="new_password_confirm" name="new_password_confirm" type="password" autocomplete="new-password">
|
||||
</div>
|
||||
|
||||
<h3>DMARC aggregate reports</h3>
|
||||
<div class="card">
|
||||
<h2>DMARC aggregate reports</h2>
|
||||
<p class="muted">Optional default <code>rua=</code> address for every sending
|
||||
domain (can be overridden per domain). Use a mailbox on a domain that
|
||||
receives inbound mail. SelfPost is send-only today; a future release will
|
||||
@@ -58,11 +67,61 @@
|
||||
{{end}}</span>{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit">Save changes</button>
|
||||
<p class="muted">Leave both new-password fields empty to change the username
|
||||
or DMARC address only. Changing the password signs out every other session;
|
||||
this one stays signed in.</p>
|
||||
</form>
|
||||
{{else}}
|
||||
<div class="card narrow">
|
||||
<h2>Panel credentials</h2>
|
||||
<p class="muted">These are the credentials for this control panel only.
|
||||
Applications keep their own logins and passwords, which are not affected.</p>
|
||||
<form method="post" action="/settings">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" name="username" autocomplete="username"
|
||||
autocapitalize="none" spellcheck="false" value="{{.FormUsername}}" required>
|
||||
|
||||
<label for="current_password">Current password</label>
|
||||
<input id="current_password" name="current_password" type="password"
|
||||
autocomplete="current-password" required>
|
||||
|
||||
<label for="new_password">New password</label>
|
||||
<input id="new_password" name="new_password" type="password" autocomplete="new-password">
|
||||
|
||||
<label for="new_password_confirm">Confirm new password</label>
|
||||
<input id="new_password_confirm" name="new_password_confirm" type="password" autocomplete="new-password">
|
||||
|
||||
<button type="submit">Save changes</button>
|
||||
</form>
|
||||
<p class="muted">Leave both new-password fields empty to change the username
|
||||
or DMARC address only. Changing the password signs out every other session;
|
||||
only. Changing the password signs out every other session;
|
||||
this one stays signed in.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="card" id="rate-limits">
|
||||
<h2>Sending rate limits</h2>
|
||||
<p class="muted">Configured in <code>.env</code> / Compose; restart the
|
||||
container to change level 1. Domain and application ceilings are set on
|
||||
each domain's page.</p>
|
||||
|
||||
<label>Level 1 — per client IP (Postfix)</label>
|
||||
<p class="code-row"><span class="code">{{.L1Messages}} messages / {{.L1Window}} seconds</span></p>
|
||||
<p class="muted"><code>RATE_LIMIT_MESSAGES_PER_IP</code> /
|
||||
<code>RATE_LIMIT_WINDOW_SECONDS</code>. Hard ceiling for every connecting IP;
|
||||
the panel cannot raise a domain or application limit above this.</p>
|
||||
|
||||
<label>Level 2 — domain</label>
|
||||
<p class="muted">Optional ceiling for <em>all</em> senders on a domain. When
|
||||
unset, only level 1 applies. Must be ≤ level 1.</p>
|
||||
|
||||
<label>Level 2 — application (trusted IPs)</label>
|
||||
<p class="muted">Optional override: list client IPs and a ceiling
|
||||
<em>strictly above</em> the domain limit (still ≤ level 1). Those IPs
|
||||
skip the domain check; everyone else stays under the domain (or level 1).</p>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -1,34 +1,14 @@
|
||||
{{/* Wide so Machine|Processes and the other .split pairs fill the column
|
||||
rather than the 48rem reading measure. */}}
|
||||
{{define "wide"}}wide{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<h1>Server status</h1>
|
||||
<h1>Status</h1>
|
||||
|
||||
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||
|
||||
{{template "status_body" .}}
|
||||
|
||||
<div class="card" id="hostname">
|
||||
<h2>Hostname and reverse DNS <span class="st st-{{.PTR.Status}}">{{.PTR.Status}}</span></h2>
|
||||
<p class="muted">Receiving servers check that the name this server announces
|
||||
resolves to its address <em>and</em> that the address resolves back to the same
|
||||
name (forward-confirmed reverse DNS). A missing or mismatched reverse record is
|
||||
the most common reason self-hosted mail is rejected or scored as spam. The
|
||||
reverse record is set at the hosting provider, not in the domain's DNS zone.</p>
|
||||
|
||||
<label>Server hostname</label>
|
||||
<span class="code">{{if .Hostname}}{{.Hostname}}{{else}}(SELFPOST_HOSTNAME is not set){{end}}</span>
|
||||
|
||||
{{if .PTR.Records}}
|
||||
<label>Forward and reverse lookup</label>
|
||||
<span class="code">{{range .PTR.Records}}{{.}}
|
||||
{{end}}</span>
|
||||
{{end}}
|
||||
|
||||
<p class="{{if eq .PTR.Status "ok"}}muted{{else}}error{{end}}">{{.PTR.Detail}}</p>
|
||||
|
||||
<form class="inline" method="post" action="/status/recheck">
|
||||
<button type="submit">Re-check DNS</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card" id="configuration">
|
||||
<h2>Configuration</h2>
|
||||
<p class="muted">Regenerates the OpenDKIM and Postfix configuration from the
|
||||
@@ -41,22 +21,3 @@
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* The status page's section index, shown in the navigation column (see the
|
||||
"sections" block in layout.html). The first six cards are the ones the
|
||||
polling fragment replaces on a timer; their ids are part of
|
||||
status_body.html and do not change with the reading, so the links here hold
|
||||
across a refresh. */}}
|
||||
{{define "sections"}}
|
||||
<div class="sections">
|
||||
<p class="sections-title">On this page</p>
|
||||
<a href="#overall">Overall</a>
|
||||
<a href="#processes">Processes</a>
|
||||
<a href="#machine">Machine</a>
|
||||
<a href="#queue">Mail queue</a>
|
||||
<a href="#certificate">TLS certificate</a>
|
||||
<a href="#sockets">Milter sockets</a>
|
||||
<a href="#hostname">Hostname and reverse DNS</a>
|
||||
<a href="#configuration">Configuration</a>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -5,108 +5,125 @@
|
||||
<p class="muted">{{.OverallHeading}}</p>
|
||||
</div>
|
||||
|
||||
<div class="card" id="processes">
|
||||
<h2>Processes <span class="st st-{{.ProcessStatus}}">{{.ProcessStatus}}</span></h2>
|
||||
{{if .ProcessError}}
|
||||
<p class="error">Could not ask supervisord for the process list.</p>
|
||||
{{else}}
|
||||
<table>
|
||||
<thead><tr><th>Program</th><th>State</th><th>Detail</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Processes}}
|
||||
<tr>
|
||||
<td>{{.Name}}</td>
|
||||
<td><span class="st st-{{.Status}}">{{.State}}</span></td>
|
||||
<td class="muted">{{.Detail}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
<div class="split">
|
||||
<div class="card" id="machine">
|
||||
<h2>Machine <span class="st st-{{.Machine.Status}}">{{.Machine.Status}}</span></h2>
|
||||
<table>
|
||||
<thead><tr><th class="metric">Resource</th><th>Usage</th><th>Detail</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="metric">CPU</td>
|
||||
<td class="metric">
|
||||
{{if .Machine.CPU.Measured}}
|
||||
<meter value="{{.Machine.CPU.Percent}}" min="0" max="100" low="70" high="90" optimum="10">{{.Machine.CPU.BusyText}}</meter>
|
||||
{{.Machine.CPU.BusyText}}
|
||||
{{else}}<span class="muted">—</span>{{end}}
|
||||
</td>
|
||||
<td class="muted">{{.Machine.CPU.Detail}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metric">Memory</td>
|
||||
<td class="metric">
|
||||
{{if .Machine.Memory.Measured}}
|
||||
<meter value="{{.Machine.Memory.Percent}}" min="0" max="100" low="70" high="90" optimum="10">{{.Machine.Memory.PctText}}</meter>
|
||||
{{.Machine.Memory.PctText}}
|
||||
{{else}}<span class="muted">—</span>{{end}}
|
||||
</td>
|
||||
<td class="muted">{{.Machine.Memory.Detail}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metric">Network</td>
|
||||
<td class="metric">
|
||||
{{if .Machine.Network.Measured}}
|
||||
↓ {{.Machine.Network.InRateText}}<br>↑ {{.Machine.Network.OutRateText}}
|
||||
{{else}}<span class="muted">—</span>{{end}}
|
||||
</td>
|
||||
<td class="muted">
|
||||
{{range .Machine.Network.Interfaces}}
|
||||
<div>{{.Name}}: {{.InText}} in, {{.OutText}} out</div>
|
||||
{{end}}
|
||||
{{if .Machine.Network.Detail}}<div>{{.Machine.Network.Detail}}</div>{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card" id="processes">
|
||||
<h2>Processes <span class="st st-{{.ProcessStatus}}">{{.ProcessStatus}}</span></h2>
|
||||
{{if .ProcessError}}
|
||||
<p class="error">Could not ask supervisord for the process list.</p>
|
||||
{{else}}
|
||||
<table>
|
||||
<thead><tr><th>Program</th><th>State</th><th>Detail</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Processes}}
|
||||
<tr>
|
||||
<td>{{.Name}}</td>
|
||||
<td><span class="st st-{{.Status}}">{{.State}}</span></td>
|
||||
<td class="muted">{{.Detail}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="machine">
|
||||
<h2>Machine <span class="st st-{{.Machine.Status}}">{{.Machine.Status}}</span></h2>
|
||||
<p class="muted">Processor, memory and network of the machine this container
|
||||
runs on, read from the kernel's counters. CPU and throughput are rates, so
|
||||
they describe{{if .Machine.WindowText}} the {{.Machine.WindowText}}{{end}}
|
||||
since the previous refresh rather than this instant.</p>
|
||||
<table>
|
||||
<thead><tr><th class="metric">Resource</th><th>Usage</th><th>Detail</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="metric">CPU</td>
|
||||
<td class="metric">
|
||||
{{if .Machine.CPU.Measured}}
|
||||
<meter value="{{.Machine.CPU.Percent}}" min="0" max="100" low="70" high="90" optimum="10">{{.Machine.CPU.BusyText}}</meter>
|
||||
{{.Machine.CPU.BusyText}}
|
||||
{{else}}<span class="muted">—</span>{{end}}
|
||||
</td>
|
||||
<td class="muted">{{.Machine.CPU.Detail}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metric">Memory</td>
|
||||
<td class="metric">
|
||||
{{if .Machine.Memory.Measured}}
|
||||
<meter value="{{.Machine.Memory.Percent}}" min="0" max="100" low="70" high="90" optimum="10">{{.Machine.Memory.PctText}}</meter>
|
||||
{{.Machine.Memory.PctText}}
|
||||
{{else}}<span class="muted">—</span>{{end}}
|
||||
</td>
|
||||
<td class="muted">{{.Machine.Memory.Detail}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="metric">Network</td>
|
||||
<td class="metric">
|
||||
{{if .Machine.Network.Measured}}
|
||||
↓ {{.Machine.Network.InRateText}}<br>↑ {{.Machine.Network.OutRateText}}
|
||||
{{else}}<span class="muted">—</span>{{end}}
|
||||
</td>
|
||||
<td class="muted">
|
||||
{{range .Machine.Network.Interfaces}}
|
||||
<div>{{.Name}}: {{.InText}} in, {{.OutText}} out{{if .Measured}} (↓ {{.InRateText}} ↑ {{.OutRateText}}){{end}}</div>
|
||||
{{end}}
|
||||
{{if .Machine.Network.Detail}}<div>{{.Machine.Network.Detail}}</div>{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="split">
|
||||
<div class="card" id="queue">
|
||||
<h2>Mail queue <span class="st st-{{.QueueStatus}}">{{.QueueStatus}}</span></h2>
|
||||
{{if .QueueError}}
|
||||
<p class="error">{{.QueueError}}</p>
|
||||
{{else}}
|
||||
<p>{{if .QueueSummary}}{{.QueueSummary}}{{else}}Mail queue is empty.{{end}}</p>
|
||||
<a class="btn" href="/mail-queue">View queue</a>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card" id="certificate">
|
||||
<h2>TLS certificate <span class="st st-{{.Cert.Status}}">{{.Cert.Status}}</span></h2>
|
||||
{{if not .Cert.NotAfter.IsZero}}
|
||||
<label>Expires</label>
|
||||
<span class="code">{{.Cert.NotAfter.UTC.Format "2006-01-02 15:04 UTC"}}</span>
|
||||
{{end}}
|
||||
<p class="{{if eq .Cert.Status "ok"}}muted{{else}}error{{end}}">{{.Cert.Detail}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="queue">
|
||||
<h2>Mail queue <span class="st st-{{.QueueStatus}}">{{.QueueStatus}}</span></h2>
|
||||
{{if .QueueError}}
|
||||
<p class="error">{{.QueueError}}</p>
|
||||
{{else}}
|
||||
<p>{{if .QueueSummary}}{{.QueueSummary}}{{else}}Mail queue is empty.{{end}}</p>
|
||||
<a class="btn" href="/mail-queue">Full queue</a>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="split">
|
||||
<div class="card" id="sockets">
|
||||
<h2>Milter sockets <span class="st st-{{.SocketStatus}}">{{.SocketStatus}}</span></h2>
|
||||
<table>
|
||||
<thead><tr><th>Milter</th><th>State</th><th>Detail</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Sockets}}
|
||||
<tr>
|
||||
<td>{{.Name}}</td>
|
||||
<td><span class="st st-{{.Status}}">{{.Status}}</span></td>
|
||||
<td class="muted">{{.Detail}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card" id="certificate">
|
||||
<h2>TLS certificate <span class="st st-{{.Cert.Status}}">{{.Cert.Status}}</span></h2>
|
||||
<p class="muted">The certificate Postfix serves on port 465{{if .Cert.Subject}} ({{.Cert.Subject}}){{end}}.
|
||||
It is supplied by the reverse proxy through a read-only mount; SelfPost only reads it.</p>
|
||||
{{if not .Cert.NotAfter.IsZero}}
|
||||
<label>Expires</label>
|
||||
<span class="code">{{.Cert.NotAfter.UTC.Format "2006-01-02 15:04 UTC"}}</span>
|
||||
{{end}}
|
||||
<p class="{{if eq .Cert.Status "ok"}}muted{{else}}error{{end}}">{{.Cert.Detail}}</p>
|
||||
</div>
|
||||
<div class="card" id="hostname">
|
||||
<h2>Hostname and reverse DNS <span class="st st-{{.PTR.Status}}">{{.PTR.Status}}</span></h2>
|
||||
|
||||
<div class="card" id="sockets">
|
||||
<h2>Milter sockets <span class="st st-{{.SocketStatus}}">{{.SocketStatus}}</span></h2>
|
||||
<table>
|
||||
<thead><tr><th>Milter</th><th>Socket</th><th>State</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Sockets}}
|
||||
<tr>
|
||||
<td>{{.Name}}</td>
|
||||
<td class="muted">{{.Path}}</td>
|
||||
<td><span class="st st-{{.Status}}">{{.Status}}</span> {{.Detail}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<label>Server hostname</label>
|
||||
<span class="code">{{if .Hostname}}{{.Hostname}}{{else}}(SELFPOST_HOSTNAME is not set){{end}}</span>
|
||||
|
||||
{{if .PTR.Records}}
|
||||
<label>Forward and reverse lookup</label>
|
||||
<span class="code">{{range .PTR.Records}}{{.}}
|
||||
{{end}}</span>
|
||||
{{end}}
|
||||
|
||||
<form class="inline" method="post" action="/status/recheck">
|
||||
<button type="submit">Re-check DNS</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
{{define "content"}}
|
||||
<h1>{{if .IsEdit}}Edit user{{else}}Create user{{end}}</h1>
|
||||
|
||||
{{template "back_link" (back "/users" "Back to users")}}
|
||||
|
||||
<div class="card narrow">
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
<form method="post" action="{{if .IsEdit}}/users/{{.UserID}}{{else}}/users/new{{end}}">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" name="username" autocomplete="username"
|
||||
autocapitalize="none" spellcheck="false" value="{{.FormUsername}}" required>
|
||||
|
||||
<label for="password">Password{{if .IsEdit}} (leave empty to keep){{end}}</label>
|
||||
<input id="password" name="password" type="password" autocomplete="new-password" {{if not .IsEdit}}required{{end}}>
|
||||
|
||||
<label for="role">Role</label>
|
||||
{{if .LastGlobalLocked}}<input type="hidden" name="role" value="{{.FormRole}}">{{end}}
|
||||
<select id="role" name="role" data-global-role="{{.GlobalRole}}"{{if .LastGlobalLocked}} disabled{{end}}>
|
||||
<option value="domain_admin" {{if eq .FormRole "domain_admin"}}selected{{end}}>Domain administrator</option>
|
||||
<option value="global" {{if eq .FormRole "global"}}selected{{end}}>Global administrator</option>
|
||||
</select>
|
||||
{{if .LastGlobalLocked}}<p class="muted">The only global administrator cannot be demoted.</p>{{end}}
|
||||
|
||||
<fieldset id="domain-pick" data-domain-pick>
|
||||
<legend>Assigned domains</legend>
|
||||
<p class="muted">Required for domain administrators.</p>
|
||||
{{range .Domains}}
|
||||
<label class="check">
|
||||
<input type="checkbox" name="domain_ids" value="{{.ID}}" {{if index $.FormDomains .ID}}checked{{end}}>
|
||||
{{.Name}}
|
||||
</label>
|
||||
{{end}}
|
||||
</fieldset>
|
||||
|
||||
<button type="submit">{{if .IsEdit}}Save{{else}}Create{{end}}</button>
|
||||
{{if .IsEdit}}
|
||||
<button type="submit" name="action" value="delete" class="danger"{{if .LastGlobalLocked}} disabled{{end}}>Delete user</button>
|
||||
{{if .LastGlobalLocked}}<p class="muted">The only global administrator cannot be deleted.</p>{{end}}
|
||||
{{end}}
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,28 @@
|
||||
{{define "content"}}
|
||||
<h1>Users</h1>
|
||||
|
||||
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||
|
||||
<div class="card">
|
||||
<p><a href="/users/new">Create user</a></p>
|
||||
{{if .Users}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Username</th><th>Role</th><th>Domains</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Users}}
|
||||
<tr>
|
||||
<td>{{.User.Username}}</td>
|
||||
<td>{{if eq .User.Role "global"}}Global{{else}}Domain admin{{end}}</td>
|
||||
<td class="muted">{{if eq .User.Role "global"}}All{{else}}{{range $i, $n := .DomainNames}}{{if $i}}, {{end}}{{$n}}{{end}}{{end}}</td>
|
||||
<td class="actions"><a href="/users/{{.User.ID}}">Edit</a></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="muted">No panel users besides the global administrator yet.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -29,79 +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.
|
||||
wantAnchors := map[string]string{
|
||||
"status": `href="#certificate"`,
|
||||
"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; both are checked with a credential shown,
|
||||
// which is the domain page's one conditional entry.
|
||||
for _, name := range []string{"status", "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
|
||||
// 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.
|
||||
@@ -207,8 +134,9 @@ func TestNavMarksActivePage(t *testing.T) {
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
err = engine.Page("dashboard").ExecuteTemplate(&buf, "nav", map[string]any{
|
||||
"User": "admin",
|
||||
"Active": "mail_queue",
|
||||
"User": "admin",
|
||||
"Active": "mail_queue",
|
||||
"IsGlobal": true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("execute nav: %v", err)
|
||||
@@ -234,8 +162,9 @@ func TestNavLeadsWithStatusAndPointsDomainsAtItsOwnPath(t *testing.T) {
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := engine.Page("status").ExecuteTemplate(&buf, "nav", map[string]any{
|
||||
"User": "admin",
|
||||
"Active": "status",
|
||||
"User": "admin",
|
||||
"Active": "status",
|
||||
"IsGlobal": true,
|
||||
}); err != nil {
|
||||
t.Fatalf("execute nav: %v", err)
|
||||
}
|
||||
@@ -261,7 +190,7 @@ func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
wide := map[string]bool{"deliveries": true, "delivery": true, "mail_queue": true, "system_log": true}
|
||||
wide := map[string]bool{"settings": true, "deliveries": true, "delivery": true, "mail_queue": true, "status": true, "system_log": true, "domain_detail": true}
|
||||
for name, page := range engine.Pages() {
|
||||
var buf bytes.Buffer
|
||||
if err := page.ExecuteTemplate(&buf, "wide", nil); err != nil {
|
||||
@@ -277,6 +206,101 @@ func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The domain page pairs cards the same way Status does: three .split rows
|
||||
// (DKIM+SPF|DMARC, connection|add-app, export|danger). DNS status, Applications
|
||||
// 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) {
|
||||
body, err := fs.ReadFile(assetsFS, "templates/domain_detail.html")
|
||||
if err != nil {
|
||||
t.Fatalf("read domain_detail: %v", err)
|
||||
}
|
||||
src := string(body)
|
||||
if got := strings.Count(src, `class="split"`); got != 3 {
|
||||
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{
|
||||
`id="dkim-spf"`, `id="dns-status"`, `id="dmarc"`,
|
||||
`id="connection"`, `id="add-application"`, `id="applications"`,
|
||||
`id="domain-settings"`, `id="export"`, `id="danger"`,
|
||||
} {
|
||||
if !strings.Contains(src, id) {
|
||||
t.Errorf("domain detail is missing %s", id)
|
||||
}
|
||||
}
|
||||
if strings.Contains(src, `id="rate-limit"`) {
|
||||
t.Error("domain rate limit should live inside domain-settings, not its own card")
|
||||
}
|
||||
if strings.Contains(src, `id="d_ips"`) {
|
||||
t.Error("domain rate limit must not ask for client IPs")
|
||||
}
|
||||
if !strings.Contains(src, "{{.L1Messages}}") && !strings.Contains(src, "{{$.L1Messages}}") {
|
||||
t.Error("domain rate limit should show the L1 message count")
|
||||
}
|
||||
if !strings.Contains(src, "Level 1 backstop") {
|
||||
t.Error("domain rate limit should show a Level 1 backstop line")
|
||||
}
|
||||
if !strings.Contains(src, "Trusted client IPs") {
|
||||
t.Error("application override should ask for trusted client IPs")
|
||||
}
|
||||
if strings.Contains(src, `id="spf-dmarc"`) {
|
||||
t.Error("SPF should sit with DKIM, not with DMARC")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsPageDocumentsRateLimits(t *testing.T) {
|
||||
body, err := fs.ReadFile(assetsFS, "templates/settings.html")
|
||||
if err != nil {
|
||||
t.Fatalf("read settings: %v", err)
|
||||
}
|
||||
src := string(body)
|
||||
if !strings.Contains(src, `id="rate-limits"`) {
|
||||
t.Error("settings should include a sending rate limits card")
|
||||
}
|
||||
for _, want := range []string{
|
||||
"RATE_LIMIT_MESSAGES_PER_IP",
|
||||
"Level 2 — domain",
|
||||
"trusted IPs",
|
||||
"{{.L1Messages}} messages / {{.L1Window}} seconds",
|
||||
} {
|
||||
if !strings.Contains(src, want) {
|
||||
t.Errorf("settings rate limits card missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrillDownPagesPlaceBackLinkAboveContent(t *testing.T) {
|
||||
drillDown := map[string]bool{
|
||||
"user_form.html": true,
|
||||
"domain_detail.html": true,
|
||||
"domain_delete.html": true,
|
||||
"delivery.html": true,
|
||||
}
|
||||
forEachTemplate(t, func(name, body string) {
|
||||
if !drillDown[name] {
|
||||
return
|
||||
}
|
||||
if !strings.Contains(body, `template "back_link"`) {
|
||||
t.Errorf("%s is a drill-down page but does not use the shared back_link template", name)
|
||||
}
|
||||
backIdx := strings.Index(body, `template "back_link"`)
|
||||
cardIdx := strings.Index(body, `class="card`)
|
||||
if cardIdx >= 0 && backIdx > cardIdx {
|
||||
t.Errorf("%s places the back link after the first card", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Since the panel root redirects to the status page, a link left pointing at
|
||||
// "/" silently lands on the wrong screen instead of failing — so no template may
|
||||
// contain one.
|
||||
@@ -351,17 +375,38 @@ func TestStatusPageRendersEveryCheck(t *testing.T) {
|
||||
"opendkim", "FATAL", "Mail queue is empty", "mail.example.com",
|
||||
"203.0.113.10 → no PTR record", `action="/reload"`,
|
||||
`hx-get="/status/fragment"`, `class="st st-error"`,
|
||||
// Three .split rows inside the polled fragment: machine|processes,
|
||||
// queue|certificate, and sockets|hostname. Ids stay on the cards.
|
||||
`id="processes"`, `id="machine"`, `id="queue"`, `id="certificate"`, `id="sockets"`, `id="hostname"`,
|
||||
`action="/status/recheck"`,
|
||||
// The machine card: the bars carry their reading in an attribute
|
||||
// (the CSP rules out sizing them with a style), and the figures are
|
||||
// printed beside them for anything that does not render a meter.
|
||||
`<meter value="12"`, `<meter value="50"`,
|
||||
"load average 0.31, 0.24, 0.19", "2.0 GiB used of 4.0 GiB",
|
||||
"4 cores · 4 threads", "2.0 GiB used of 4.0 GiB",
|
||||
"eth0: 1.0 MiB in, 512.0 KiB out",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("status page is missing %q", want)
|
||||
}
|
||||
}
|
||||
if got := strings.Count(out, `class="split"`); got != 3 {
|
||||
t.Errorf("status page has %d .split rows, want 3", got)
|
||||
}
|
||||
// Hostname must live inside the fragment so a poll refresh keeps it beside
|
||||
// sockets; Configuration stays outside (static reload control).
|
||||
body := strings.Index(out, `id="status-body"`)
|
||||
conf := strings.Index(out, `id="configuration"`)
|
||||
if body < 0 || conf < 0 || conf < body {
|
||||
t.Fatal("status-body or configuration card missing or out of order")
|
||||
}
|
||||
frag := out[body:conf]
|
||||
if !strings.Contains(frag, `id="hostname"`) {
|
||||
t.Error("hostname card is outside the polled status-body fragment")
|
||||
}
|
||||
if strings.Contains(frag, `id="configuration"`) || strings.Contains(frag, `action="/reload"`) {
|
||||
t.Error("configuration reload must stay outside the polled fragment")
|
||||
}
|
||||
}
|
||||
|
||||
// A machine whose counters could not be read — no /proc, or a first reading
|
||||
@@ -425,13 +470,13 @@ func statusPageData() map[string]any {
|
||||
},
|
||||
"Machine": health.Machine{
|
||||
CPU: health.CPU{
|
||||
Measured: true, BusyPct: 12.4, Cores: 4,
|
||||
Measured: true, BusyPct: 12.4, Cores: 4, Threads: 4,
|
||||
Load: [3]float64{0.31, 0.24, 0.19}, HasLoad: true,
|
||||
Status: health.StatusOK, Detail: "4 core(s) · load average 0.31, 0.24, 0.19",
|
||||
Status: health.StatusOK, Detail: "4 cores · 4 threads",
|
||||
},
|
||||
Memory: health.Memory{
|
||||
Measured: true, TotalBytes: 4 << 30, UsedBytes: 2 << 30, UsedPct: 50,
|
||||
Status: health.StatusOK, Detail: "2.0 GiB used of 4.0 GiB; 2.0 GiB available to new work.",
|
||||
Status: health.StatusOK, Detail: "2.0 GiB used of 4.0 GiB.",
|
||||
},
|
||||
Network: health.Network{
|
||||
Measured: true, RxRate: 2048, TxRate: 1024,
|
||||
@@ -444,7 +489,7 @@ func statusPageData() map[string]any {
|
||||
Status: health.StatusOK,
|
||||
},
|
||||
"Sockets": []health.Socket{
|
||||
{Name: "OpenDKIM", Path: "/run/opendkim/opendkim.sock", Present: true, Status: health.StatusOK, Detail: "Listening."},
|
||||
{Name: "OpenDKIM", Path: "/run/opendkim/opendkim.sock", Present: true, Status: health.StatusOK, Detail: "Listening"},
|
||||
},
|
||||
"SocketStatus": health.StatusOK,
|
||||
"OverallStatus": health.StatusError,
|
||||
|
||||
@@ -34,7 +34,9 @@ var pageFiles = map[string][]string{
|
||||
"setup": {"templates/setup.html"},
|
||||
"login": {"templates/login.html"},
|
||||
"dashboard": {"templates/dashboard.html"},
|
||||
"account": {"templates/account.html"},
|
||||
"settings": {"templates/settings.html"},
|
||||
"users": {"templates/users.html"},
|
||||
"user_form": {"templates/user_form.html"},
|
||||
"backup": {"templates/backup.html", "templates/encrypt_fields.html"},
|
||||
"domain_detail": {"templates/domain_detail.html", "templates/encrypt_fields.html"},
|
||||
"domain_delete": {"templates/domain_delete.html"},
|
||||
@@ -63,7 +65,7 @@ func New(version string) (*Engine, error) {
|
||||
}
|
||||
for name, files := range pageFiles {
|
||||
patterns := append([]string{"templates/layout.html"}, files...)
|
||||
tmpl, err := template.New("layout.html").ParseFS(assetsFS, patterns...)
|
||||
tmpl, err := template.New("layout.html").Funcs(templateFuncs()).ParseFS(assetsFS, patterns...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse template %s: %w", name, err)
|
||||
}
|
||||
@@ -79,6 +81,17 @@ func New(version string) (*Engine, error) {
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// templateFuncs supplies helpers shared across page templates.
|
||||
func templateFuncs() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
// back builds the map back_link reads; keeps href and label paired at
|
||||
// the call site instead of repeating the <a class="back"> markup.
|
||||
"back": func(href, label string) map[string]string {
|
||||
return map[string]string{"Href": href, "Label": label}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Page returns a parsed page template by logical name. It is exported for
|
||||
// template guard tests that assert structural properties across all pages.
|
||||
func (e *Engine) Page(name string) *template.Template {
|
||||
|
||||
Reference in New Issue
Block a user