[1.3.0] Security and quality after 1.2.5: domain-admin send-log authorization, fail-closed sign-in and application delete, level-2 rate-limit race fix, restore Resync, expanded tests, operator docs, release CI, and OFL for IBM Plex. Upgrading from 1.2.x is a tag bump; no migration. Added: - licence: the SIL Open Font License 1.1 text now travels with the IBM Plex - docs: agreed roadmap item **queue-retries** — show this Postfix's retry Security: - The independent security review of the send-log authorization and Fixed: - test (e2e): send-log status scrapers follow the badge markup in - security (panel): the Deliveries list is scoped to a domain administrator's - mail (level-2 rate limit): the ceiling is no longer overshot by messages that - panel (sign-in): a session that cannot be written to the database no longer - panel (applications): deleting an application removes its SASL credentials … (2 more in CHANGELOG) Changed: - docs: operator and as-built docs aligned with the code after a full - docs: [guide.md](docs/guide.md) reorganised into **Installation**, **Instance - docs: the 2026-08-13 full-tree review plan is complete — every phase (P0–P7) - licence: [NOTICE](NOTICE) tells modifiers to update `SourceURL` in - ci: the release image is published only for a **published** GitHub Release … (9 more in CHANGELOG) [1.3.1] Retry policy in the panel, persistent Postfix queue, and self-contained full backups. Mail queue and delivery history show this Postfix's first retry, backoff cap, and queue lifetime. Deferred mail survives container recreate. Full backups now archive `data/`, compose, `.env`, and `certs/`; the archive layout breaks 1.3.0 flat backups (unpack those with `tar xzf backup.tar.gz -C ./data` as before). Upgrading from 1.3.0 is a tag bump; no schema migration. Added: - Mail queue and a delivery's history show this Postfix's retry policy - docs: **Plan checklists** in [development.md](docs/development.md) — format, - docs: **Implementation checklists** with model routing in - docs: roadmap candidates **send-log-retention** (panel Settings for delivery - Postfix queue under `/data/postfix/queue` — deferred and active mail survive … (1 more in CHANGELOG) Changed: - **Breaking:** full-backup archive layout — paths are prefixed with `data/`; - docs: operator guide, architecture, security, and backup UI updated for - docs: HTML mockups for a full panel UI refresh Changed: - docs: panel UI mockups after review — Status restored to live readings - docs: panel UI design system and mockups rebuilt as separate pages - docs: panel UI mockups — add-domain sits in the list card (Domains and Fixed: - ci (GHCR): per-arch package tags (`X.Y.Z-amd64`, `X.Y.Z-arm64`) are dropped Squashed CHANGELOG sections: [1.3.0], [1.3.1]. Full detail: CHANGELOG.md (1.3.0, 1.3.1).
416 lines
18 KiB
HTML
416 lines
18 KiB
HTML
{{/* Wide so the .split pairs fill the column rather than the 48rem reading
|
|
measure (same pattern as Status). */}}
|
|
{{define "wide"}}wide{{end}}
|
|
|
|
{{/* The Host/name ‖ Type field-pair repeats for every DNS record this page
|
|
shows (DKIM, SPF, DMARC, report authorization) in both the status card
|
|
and the publishable-record cards below it — only the host and whether it
|
|
carries a Copy button change. Two variants rather than one templated
|
|
Copy flag: the DNS status card never offers Copy (its host is derived,
|
|
not something to paste), the record cards always do. */}}
|
|
{{define "host_type"}}
|
|
<div class="field-pair host-type">
|
|
<div>
|
|
<label>Host / name</label>
|
|
<span class="code">{{.}}</span>
|
|
</div>
|
|
<div class="field-type">
|
|
<label>Type</label>
|
|
<span class="code">TXT</span>
|
|
</div>
|
|
</div>
|
|
{{end}}
|
|
|
|
{{define "host_type_copy"}}
|
|
<div class="field-pair host-type">
|
|
<div>
|
|
<label>Host / name</label>
|
|
<div class="code-row">
|
|
<span class="code">{{.}}</span>
|
|
<button type="button" class="copy">Copy</button>
|
|
</div>
|
|
</div>
|
|
<div class="field-type">
|
|
<label>Type</label>
|
|
<span class="code">TXT</span>
|
|
</div>
|
|
</div>
|
|
{{end}}
|
|
|
|
{{/* Value partials pair with the two host_type variants above: field_values
|
|
is the DNS status card's raw record dump (no Copy — those values are for
|
|
comparison, not for pasting), field_value is the single publishable
|
|
value on the record cards below (always has Copy). Callers still guard
|
|
the empty case, since "no records yet" and "one blank record" read
|
|
differently. */}}
|
|
{{define "field_values"}}
|
|
<label>Value</label>
|
|
<span class="code">{{range .}}{{.}}
|
|
{{end}}</span>
|
|
{{end}}
|
|
|
|
{{define "field_value"}}
|
|
<label>Value</label>
|
|
<div class="code-row">
|
|
<span class="code">{{.}}</span>
|
|
<button type="button" class="copy">Copy</button>
|
|
</div>
|
|
{{end}}
|
|
|
|
{{define "content"}}
|
|
<h1>{{.Domain.Name}}</h1>
|
|
|
|
{{template "back_link" (back "/domains" "All domains")}}
|
|
|
|
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
|
{{if .RateLimitErr}}<div class="flash error">{{.RateLimitErr}}</div>{{end}}
|
|
|
|
{{if .NewCred}}
|
|
<div class="card credential" id="new-credential">
|
|
<h2>New application password</h2>
|
|
<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>
|
|
<button type="button" class="copy">Copy</button>
|
|
</div>
|
|
<label>Password</label>
|
|
<div class="code-row">
|
|
<span class="code">{{.NewCred.Password}}</span>
|
|
<button type="button" class="copy">Copy</button>
|
|
</div>
|
|
</div>
|
|
{{end}}
|
|
|
|
{{/* Two rows of two checks (.check-cols). */}}
|
|
<div class="card" id="dns-status">
|
|
<h2>DNS status <span class="st st-{{.DNS.Overall}}">{{.DNS.Overall}}</span></h2>
|
|
<p class="muted">Cached a few minutes — use <em>Re-check</em> after
|
|
publishing.</p>
|
|
|
|
<div class="check-cols">
|
|
<div class="check-col">
|
|
<label>DKIM <span class="st st-{{.DNS.DKIM.Status}}">{{.DNS.DKIM.Status}}</span></label>
|
|
{{template "host_type" .Record.Name}}
|
|
{{if .DNS.DKIM.Records}}{{template "field_values" .DNS.DKIM.Records}}{{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>
|
|
|
|
<div class="check-col">
|
|
<label>SPF <span class="st st-{{.DNS.SPF.Status}}">{{.DNS.SPF.Status}}</span></label>
|
|
{{template "host_type" .Domain.Name}}
|
|
{{if .DNS.SPF.Records}}{{template "field_values" .DNS.SPF.Records}}{{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>
|
|
|
|
<div class="check-col">
|
|
<label>DMARC <span class="st st-{{.DNS.DMARC.Status}}">{{.DNS.DMARC.Status}}</span></label>
|
|
{{template "host_type" .DMARCName}}
|
|
{{if .DNS.DMARC.Records}}{{template "field_values" .DNS.DMARC.Records}}{{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 class="check-col">
|
|
{{if .DNS.DMARCReportAuth.Status}}
|
|
<label>Report authorization <span class="st st-{{.DNS.DMARCReportAuth.Status}}">{{.DNS.DMARCReportAuth.Status}}</span></label>
|
|
{{template "host_type" .ReportAuthName}}
|
|
{{if .DNS.DMARCReportAuth.Records}}{{template "field_values" .DNS.DMARCReportAuth.Records}}{{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="split">
|
|
<div class="card" id="dkim-spf">
|
|
<h2>DKIM and SPF records</h2>
|
|
|
|
<p class="check-col-title">DKIM</p>
|
|
{{template "host_type_copy" .Record.Name}}
|
|
{{template "field_value" .Record.Value}}
|
|
|
|
<p class="muted">Not a secret. Signed with selector
|
|
<strong>{{.Domain.DKIMSelector}}</strong>.</p>
|
|
|
|
<p class="check-col-title">SPF</p>
|
|
{{template "host_type_copy" .Domain.Name}}
|
|
{{template "field_value" .SPFExample}}
|
|
|
|
<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>
|
|
|
|
{{template "host_type_copy" .DMARCName}}
|
|
|
|
<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 on another domain.</p>
|
|
{{end}}
|
|
|
|
{{if .NeedsReportAuth}}
|
|
<p class="check-col-title">Report authorization</p>
|
|
{{template "host_type_copy" .ReportAuthName}}
|
|
{{template "field_value" .ReportAuthValue}}
|
|
{{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">SASL logins for this domain — wildcard (*@domain) or a fixed
|
|
address list.</p>
|
|
|
|
{{if .Apps}}
|
|
<ul class="apps">
|
|
{{range .Apps}}
|
|
<li class="app">
|
|
<p class="app-login">{{.Login}}</p>
|
|
<p class="app-addr muted">
|
|
{{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>
|
|
<!-- 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-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>
|
|
</form>
|
|
<form class="inline" method="post" action="/applications/{{.ID}}/delete"
|
|
data-confirm="Delete application {{.Login}}? Its credentials stop working immediately.">
|
|
<button type="submit" class="danger">Delete</button>
|
|
</form>
|
|
<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>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
{{end}}
|
|
</ul>
|
|
{{else}}
|
|
<p class="muted">No applications yet. Add one above to get started.</p>
|
|
{{end}}
|
|
</div>
|
|
|
|
<div class="card" id="domain-settings">
|
|
<h2>Domain settings</h2>
|
|
|
|
<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>
|
|
|
|
<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>
|
|
|
|
<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="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">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}}
|