release: 1.2.3

Close CHANGELOG [Unreleased] as 1.2.3; bump the compose pin and image references. Includes domain detail layout polish and Domains add-row.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-12 22:52:19 +03:00
parent f9e259a66d
commit 601e183e0c
9 changed files with 183 additions and 79 deletions
+17 -8
View File
@@ -5,17 +5,26 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
## [Unreleased] ## [Unreleased]
## [1.2.3] - 2026-08-12
Domain detail layout and panel polish after 1.2.2. Upgrading is a tag bump; no
migration.
### Changed ### Changed
- panel: the domain detail page is wide with paired cards (DKIM ‖ SPF+DMARC; - panel: the domain detail page is wide with paired cards (DKIM+SPF ‖ DMARC;
sending settings ‖ add application; export ‖ danger). DNS status, connection settings ‖ add application; export ‖ danger). DNS status,
Applications and Domain settings are full-width. DNS status is two rows Applications and Domain settings are full-width. DNS status is two rows
(DKIM ‖ SPF, DMARC ‖ report authorization); Domain settings pairs DMARC (DKIM ‖ SPF, DMARC ‖ report authorization) with Host ‖ Type (narrow TXT)
report mode with the domain rate limit; application Edit opens mode and and a Value label when records are present. Domain settings pairs DMARC
rate limit side by side; the custom rua address field is shown only for report mode with the optional level-2 domain rate limit; application Edit
Custom address. The in-nav “On this page” section index is removed (Status opens address mode and an optional level-2 application rate limit side by
already dropped it; the paired domain layout no longer needs it). Section side (with a note that domain level-2 and global level-1 still apply); the
blurbs are shorter. custom `rua=` address field is shown only for Custom address. The in-nav
“On this page” section index is removed. Shorter blurbs; *Sending server
settings* renamed **Connection settings**.
- panel: Domains list — **Add domain** sits beside the domain field; the
lead blurb under the form is dropped.
- panel: page URLs, browser titles, and headings are aligned — **Settings** is - panel: page URLs, browser titles, and headings are aligned — **Settings** is
now `/settings` (legacy `/account` redirects with 308); the domains list title now `/settings` (legacy `/account` redirects with 308); the domains list title
is `SelfPost — domains`; Status, Users, and user create/edit titles match their is `SelfPost — domains`; Status, Users, and user create/edit titles match their
+1 -1
View File
@@ -95,7 +95,7 @@ docker run --rm -d --name selfpost-try \
-e SELFPOST_HOSTNAME=mail.local.test \ -e SELFPOST_HOSTNAME=mail.local.test \
-e PANEL_COOKIE_SECURE=false \ -e PANEL_COOKIE_SECURE=false \
-v selfpost-try-data:/data \ -v selfpost-try-data:/data \
ghcr.io/mixeme/selfpost:1.2.2 ghcr.io/mixeme/selfpost:1.2.3
``` ```
**Get the setup URL** (pick one): **Get the setup URL** (pick one):
+1 -1
View File
@@ -22,7 +22,7 @@
services: services:
selfpost: selfpost:
image: ghcr.io/mixeme/selfpost:1.2.2 image: ghcr.io/mixeme/selfpost:1.2.3
restart: unless-stopped restart: unless-stopped
environment: environment:
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}" SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
+1 -1
View File
@@ -141,7 +141,7 @@ Requires Go 1.26+ and `CGO_ENABLED=0`.
```sh ```sh
make build # bin/panel, bin/selfpost-backup (VERSION=dev by default) make build # bin/panel, bin/selfpost-backup (VERSION=dev by default)
make build VERSION=1.2.2 make build VERSION=1.2.3
``` ```
Or directly: Or directly:
+1 -1
View File
@@ -381,7 +381,7 @@ but it can look like an open port in external scans.
## Fixed image tag ## Fixed image tag
`deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`), `deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`),
deliberately never `:latest`. The current pin is `1.2.2`. Intermediate deliberately never `:latest`. The current pin is `1.2.3`. Intermediate
CHANGELOG sections (`0.2.0``0.6.0`) record development cuts from before that CHANGELOG sections (`0.2.0``0.6.0`) record development cuts from before that
image was published. Pinning matters because of the backup version check above: image was published. Pinning matters because of the backup version check above:
the panel binary's embedded version and the image tag that produced it are the the panel binary's embedded version and the image tag that produced it are the
+23 -2
View File
@@ -508,8 +508,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 beside them would otherwise win the width and leave "Memory" broken across
two lines. */ two lines. */
.metric { white-space: nowrap; } .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 { 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 /* 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 together (a delivery's facts beside its history). auto-fit with a minimum
@@ -557,6 +569,15 @@ meter { width: 5rem; height: 0.7rem; vertical-align: middle; margin-right: 0.4re
} }
.field-pair > div { min-width: 0; } .field-pair > div { min-width: 0; }
.field-pair label { margin-top: 0.45rem; } .field-pair label { 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. */
.field-pair.host-type {
grid-template-columns: minmax(0, 1fr) auto;
}
.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 /* The subject heads a delivery's page, and it is the one heading in the panel
whose text we do not control: it may be a hundred characters with nothing to whose text we do not control: it may be a hundred characters with nothing to
break on. It wraps to as many lines as it needs (this is the page's name, not break on. It wraps to as many lines as it needs (this is the page's name, not
@@ -646,7 +667,7 @@ button.copy, .actions button, .actions > label.toggle, .actions a.danger, .nav b
} }
button.copy:hover, .actions button:hover, .actions > label.toggle:hover, button.copy:hover, .actions button:hover, .actions > label.toggle:hover,
.actions a.danger:hover, .nav button:hover { background: var(--surface-bg-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 { .actions button.danger, .actions a.danger, .nav button.danger {
color: var(--danger-fg); background: var(--danger-bg); border-color: var(--danger-border); color: var(--danger-fg); background: var(--danger-bg); border-color: var(--danger-border);
} }
+6 -6
View File
@@ -8,14 +8,14 @@
<h2>Add a sending domain</h2> <h2>Add a sending domain</h2>
<form method="post" action="/domains"> <form method="post" action="/domains">
<label for="name">Domain</label> <label for="name">Domain</label>
<input id="name" name="name" type="text" placeholder="example.com" <div class="input-row">
autocomplete="off" autocapitalize="none" spellcheck="false" <input id="name" name="name" type="text" placeholder="example.com"
value="{{.FormName}}" autofocus required> 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}} {{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<button type="submit">Add domain</button>
</form> </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> </div>
{{end}} {{end}}
+128 -51
View File
@@ -37,39 +37,93 @@
<div class="check-cols"> <div class="check-cols">
<div class="check-col"> <div class="check-col">
<label>DKIM <span class="st st-{{.DNS.DKIM.Status}}">{{.DNS.DKIM.Status}}</span></label> <label>DKIM <span class="st st-{{.DNS.DKIM.Status}}">{{.DNS.DKIM.Status}}</span></label>
<p class="muted">{{.Record.Name}}</p> <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"}} {{if ne .DNS.DKIM.Status "ok"}}
<p class="{{if eq .DNS.DKIM.Status "unknown"}}muted{{else}}error{{end}}">{{.DNS.DKIM.Detail}}</p> <p class="{{if eq .DNS.DKIM.Status "unknown"}}muted{{else}}error{{end}}">{{.DNS.DKIM.Detail}}</p>
{{end}} {{end}}
{{if .DNS.DKIM.Records}}<span class="code">{{range .DNS.DKIM.Records}}{{.}}
{{end}}</span>{{end}}
</div> </div>
<div class="check-col"> <div class="check-col">
<label>SPF <span class="st st-{{.DNS.SPF.Status}}">{{.DNS.SPF.Status}}</span></label> <label>SPF <span class="st st-{{.DNS.SPF.Status}}">{{.DNS.SPF.Status}}</span></label>
<p class="muted">{{.Domain.Name}}</p> <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"}} {{if ne .DNS.SPF.Status "ok"}}
<p class="{{if eq .DNS.SPF.Status "unknown"}}muted{{else}}error{{end}}">{{.DNS.SPF.Detail}}</p> <p class="{{if eq .DNS.SPF.Status "unknown"}}muted{{else}}error{{end}}">{{.DNS.SPF.Detail}}</p>
{{end}} {{end}}
<p class="muted">Shallow check: literal address only, no <code>include:</code> / <p class="muted">Shallow check: literal address only, no <code>include:</code> /
<code>redirect=</code>.</p> <code>redirect=</code>.</p>
{{if .DNS.SPF.Records}}<span class="code">{{range .DNS.SPF.Records}}{{.}}
{{end}}</span>{{end}}
</div> </div>
<div class="check-col"> <div class="check-col">
<label>DMARC <span class="st st-{{.DNS.DMARC.Status}}">{{.DNS.DMARC.Status}}</span></label> <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> <div class="field-pair host-type">
{{if .DNS.DMARC.Records}}<span class="code">{{range .DNS.DMARC.Records}}{{.}} <div>
{{end}}</span>{{end}} <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> </div>
<div class="check-col"> <div class="check-col">
{{if .DNS.DMARCReportAuth.Status}} {{if .DNS.DMARCReportAuth.Status}}
<label>Report authorization <span class="st st-{{.DNS.DMARCReportAuth.Status}}">{{.DNS.DMARCReportAuth.Status}}</span></label> <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> <p class="{{if eq .DNS.DMARCReportAuth.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DMARCReportAuth.Detail}}</p>
{{if .DNS.DMARCReportAuth.Records}}<span class="code">{{range .DNS.DMARCReportAuth.Records}}{{.}}
{{end}}</span>{{end}}
{{else}} {{else}}
<label>Report authorization</label> <label>Report authorization</label>
<p class="muted">Not required (no external <code>rua=</code>).</p> <p class="muted">Not required (no external <code>rua=</code>).</p>
@@ -83,18 +137,24 @@
</div> </div>
<div class="split"> <div class="split">
<div class="card" id="dkim"> <div class="card" id="dkim-spf">
<h2>DKIM record</h2> <h2>DKIM and SPF records</h2>
<label>Host / name</label> <p class="check-col-title">DKIM</p>
<div class="code-row"> <div class="field-pair host-type">
<span class="code">{{.Record.Name}}</span> <div>
<button type="button" class="copy">Copy</button> <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> </div>
<label>Type</label>
<span class="code">TXT</span>
<label>Value</label> <label>Value</label>
<div class="code-row"> <div class="code-row">
<span class="code">{{.Record.Value}}</span> <span class="code">{{.Record.Value}}</span>
@@ -103,21 +163,22 @@
<p class="muted">Not a secret. Signed with selector <p class="muted">Not a secret. Signed with selector
<strong>{{.Domain.DKIMSelector}}</strong>.</p> <strong>{{.Domain.DKIMSelector}}</strong>.</p>
</div>
<div class="card" id="spf-dmarc">
<h2>SPF and DMARC records</h2>
<p class="check-col-title">SPF</p> <p class="check-col-title">SPF</p>
<label>Host / name</label> <div class="field-pair host-type">
<div class="code-row"> <div>
<span class="code">{{.Domain.Name}}</span> <label>Host / name</label>
<button type="button" class="copy">Copy</button> <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> </div>
<label>Type</label>
<span class="code">TXT</span>
<label>Value</label> <label>Value</label>
<div class="code-row"> <div class="code-row">
<span class="code">{{.SPFExample}}</span> <span class="code">{{.SPFExample}}</span>
@@ -126,17 +187,25 @@
<p class="muted">Merge into an existing SPF if the domain already has one — <p class="muted">Merge into an existing SPF if the domain already has one —
do not publish a second record.</p> do not publish a second record.</p>
</div>
<p class="check-col-title">DMARC</p> <div class="card" id="dmarc">
<label>Host / name</label> <h2>DMARC record</h2>
<div class="code-row">
<span class="code">{{.DMARCName}}</span> <div class="field-pair host-type">
<button type="button" class="copy">Copy</button> <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> </div>
<label>Type</label>
<span class="code">TXT</span>
<label>Value{{if eq .DMARCSource "settings"}} <span class="muted">(from Settings)</span>{{else if eq .DMARCSource "custom"}} <span class="muted">(custom)</span>{{else if eq .DMARCSource "none"}} <span class="muted">(no reports)</span>{{end}}</label> <label>Value{{if eq .DMARCSource "settings"}} <span class="muted">(from Settings)</span>{{else if eq .DMARCSource "custom"}} <span class="muted">(custom)</span>{{else if eq .DMARCSource "none"}} <span class="muted">(no reports)</span>{{end}}</label>
<div class="code-row"> <div class="code-row">
<span class="code">{{.DMARCExample}}</span> <span class="code">{{.DMARCExample}}</span>
@@ -151,15 +220,20 @@
{{if .NeedsReportAuth}} {{if .NeedsReportAuth}}
<p class="check-col-title">Report authorization</p> <p class="check-col-title">Report authorization</p>
<label>Host / name</label> <div class="field-pair host-type">
<div class="code-row"> <div>
<span class="code">{{.ReportAuthName}}</span> <label>Host / name</label>
<button type="button" class="copy">Copy</button> <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> </div>
<label>Type</label>
<span class="code">TXT</span>
<label>Value</label> <label>Value</label>
<div class="code-row"> <div class="code-row">
<span class="code">{{.ReportAuthValue}}</span> <span class="code">{{.ReportAuthValue}}</span>
@@ -174,8 +248,8 @@
</div> </div>
<div class="split"> <div class="split">
<div class="card" id="settings"> <div class="card" id="connection">
<h2>Sending server settings</h2> <h2>Connection settings</h2>
<p class="muted">Same for every domain. Authenticate with an application <p class="muted">Same for every domain. Authenticate with an application
login from below.</p> login from below.</p>
@@ -193,8 +267,8 @@
create or regenerate.</p> create or regenerate.</p>
</div> </div>
{{/* Create form beside settings, mirroring "Add a sending domain" above the {{/* Create form beside connection settings, mirroring "Add a sending domain"
domains list. */}} above the domains list. */}}
<div class="card" id="add-application"> <div class="card" id="add-application">
<h2>Add an application</h2> <h2>Add an application</h2>
<form method="post" action="/domains/{{.Domain.ID}}/applications"> <form method="post" action="/domains/{{.Domain.ID}}/applications">
@@ -270,7 +344,10 @@
</form> </form>
</div> </div>
<div class="check-col"> <div class="check-col">
<p class="check-col-title">Rate limit</p> <p class="check-col-title">Optional level-2 rate limit</p>
<p class="muted">Caps this application only. The domain level-2
limit (<a href="#domain-settings">Domain settings</a>) and the
global level-1 limit still apply.</p>
<form id="rl-{{.ID}}" method="post" action="/applications/{{.ID}}/ratelimit"> <form id="rl-{{.ID}}" method="post" action="/applications/{{.ID}}/ratelimit">
<label>Expected client IPs (one per line or comma-separated)</label> <label>Expected client IPs (one per line or comma-separated)</label>
<textarea name="allowed_ips" rows="2" placeholder="203.0.113.10">{{.IPsText}}</textarea> <textarea name="allowed_ips" rows="2" placeholder="203.0.113.10">{{.IPsText}}</textarea>
+5 -8
View File
@@ -207,7 +207,7 @@ func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) {
} }
// The domain page pairs cards the same way Status does: three .split rows // The domain page pairs cards the same way Status does: three .split rows
// (DKIM|SPF+DMARC, settings|add-app, export|danger). DNS status, Applications // (DKIM+SPF|DMARC, connection|add-app, export|danger). DNS status, Applications
// and Domain settings are full-width; DNS status and Domain settings (and the // 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. // application Edit panel) use .check-cols. Losing a row silently stacks again.
func TestDomainDetailPageHasPairedCards(t *testing.T) { func TestDomainDetailPageHasPairedCards(t *testing.T) {
@@ -230,8 +230,8 @@ func TestDomainDetailPageHasPairedCards(t *testing.T) {
t.Error("application Edit mode and Rate limit should be one Edit button") t.Error("application Edit mode and Rate limit should be one Edit button")
} }
for _, id := range []string{ for _, id := range []string{
`id="dkim"`, `id="dns-status"`, `id="spf-dmarc"`, `id="dkim-spf"`, `id="dns-status"`, `id="dmarc"`,
`id="settings"`, `id="add-application"`, `id="applications"`, `id="connection"`, `id="add-application"`, `id="applications"`,
`id="domain-settings"`, `id="export"`, `id="danger"`, `id="domain-settings"`, `id="export"`, `id="danger"`,
} { } {
if !strings.Contains(src, id) { if !strings.Contains(src, id) {
@@ -241,11 +241,8 @@ func TestDomainDetailPageHasPairedCards(t *testing.T) {
if strings.Contains(src, `id="rate-limit"`) { if strings.Contains(src, `id="rate-limit"`) {
t.Error("domain rate limit should live inside domain-settings, not its own card") t.Error("domain rate limit should live inside domain-settings, not its own card")
} }
if strings.Contains(src, `id="spf"`) && !strings.Contains(src, `id="spf-dmarc"`) { if strings.Contains(src, `id="spf-dmarc"`) {
t.Error("standalone SPF card should be merged into spf-dmarc") t.Error("SPF should sit with DKIM, not with DMARC")
}
if regexp.MustCompile(`id="dmarc"`).MatchString(src) {
t.Error("standalone DMARC card should be merged into spf-dmarc")
} }
} }