Split internal/web into subpackages before domain-admin growth.
test / test (push) Has been cancelled

Lay out view, auth, validate, and handlers under internal/web while keeping
the cmd/panel API unchanged; update roadmap and changelog for web-split closure.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-10 23:19:09 +03:00
parent efaf016c5f
commit 155b721438
69 changed files with 1455 additions and 1528 deletions
+68
View File
@@ -0,0 +1,68 @@
{{define "content"}}
<h1>Settings</h1>
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
<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>
<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">
<h3>DMARC aggregate reports</h3>
<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
be able to receive reports in the panel itself.</p>
<label for="dmarc_report_email">Default report address</label>
<input id="dmarc_report_email" name="dmarc_report_email" type="email"
autocomplete="email" autocapitalize="none" spellcheck="false"
value="{{.FormDMARCEmail}}" placeholder="reports@your-mail-domain.com">
{{if .FormDMARCEmail}}
<p class="muted">When <code>rua=</code> points at another domain, that hub
domain must publish a report-authorisation record so receivers will deliver
the XML aggregates.</p>
<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">{{.ReportAuthExample}}</span>
<button type="button" class="copy">Copy</button>
</div>
{{if .ReportAuthDNS.Status}}
<label>Report authorization DNS <span class="st st-{{.ReportAuthDNS.Status}}">{{.ReportAuthDNS.Status}}</span></label>
<p class="{{if eq .ReportAuthDNS.Status "ok"}}muted{{else}}error{{end}}">{{.ReportAuthDNS.Detail}}</p>
{{if .ReportAuthDNS.Records}}<span class="code">{{range .ReportAuthDNS.Records}}{{.}}
{{end}}</span>{{end}}
{{end}}
{{end}}
<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;
this one stays signed in.</p>
</div>
{{end}}
+41
View File
@@ -0,0 +1,41 @@
{{define "content"}}
<h1>Backup &amp; migration</h1>
<div class="card">
<h2>Full backup</h2>
<p class="muted">Download a full backup of all persistent state — the database,
every domain's DKIM key and the application credentials. Use it to move the
whole server to a new machine: restore it into a container of the
<strong>same SelfPost version</strong>, with the same data mount, before first
start. TLS certificates and the mail queue are not included.</p>
<p class="muted"><strong>The backup file is a secret</strong> (it contains
private keys and credentials). Store and transfer it securely and delete it
once the restore succeeds. Encrypting it below is the simplest way to do that:
the download is then a <code>.spbk</code> file (SelfPost backup) that only
the password opens.</p>
{{if .BackupErr}}<p class="error">{{.BackupErr}}</p>{{end}}
<form method="post" action="/backup">
{{template "encryptfields" .}}
<button type="submit">Download full backup</button>
</form>
</div>
<div class="card">
<h2>Import a domain</h2>
<p class="muted">Move a single domain here from another SelfPost instance using
a domain export file (from that domain's page) — plain <code>.json</code> or
encrypted <code>.spde</code> (SelfPost domain export). Its DKIM key and
application passwords come across, so the published DNS record needs no
change. The export file is a secret, like a full backup.</p>
{{if .ImportErr}}<p class="error">{{.ImportErr}}</p>{{end}}
<form method="post" action="/domains/import" enctype="multipart/form-data">
<label for="importfile">Domain export file</label>
<input id="importfile" name="file" type="file" accept=".json,.spde,application/json" required data-import-file>
<div class="encrypt-fields" data-import-password-fields>
<label for="importpw">Password</label>
<input id="importpw" name="import_password" type="password" autocomplete="off">
</div>
<button type="submit">Import domain</button>
</form>
</div>
{{end}}
@@ -0,0 +1,46 @@
{{define "content"}}
<h1>Domains</h1>
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
<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>
{{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>
<div class="card">
<h2>Domains</h2>
{{if .Domains}}
<table>
<thead>
<tr><th>Domain</th><th>DNS</th><th>Selector</th><th>Apps</th><th></th></tr>
</thead>
<tbody>
{{range .Domains}}
<tr>
<td><a href="/domains/{{.ID}}">{{.Name}}</a></td>
<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>
</tr>
{{end}}
</tbody>
</table>
<p class="muted">The <em>DNS</em> badge is the worst of the domain's DKIM, SPF
and DMARC checks. Results are cached for a few minutes; open a domain for the
details and a <em>Re-check</em> button.</p>
{{else}}
<p class="muted">No domains yet. Add one above to get started.</p>
{{end}}
</div>
{{end}}
@@ -0,0 +1,31 @@
{{/* The send log is six columns of addresses, subjects and timestamps, so this
page takes the whole column rather than the reading measure (see the "wide"
block in layout.html). It is declared per page rather than per page name
because how wide a page needs to be is a property of that page, not of the
navigation entry several pages share. */}}
{{define "wide"}}wide{{end}}
{{define "content"}}
<h1>Deliveries</h1>
<div class="card">
<h2>Filter</h2>
<form method="get" action="/deliveries">
<label for="domain">Domain</label>
<select id="domain" name="domain">
<option value="">All domains</option>
{{range .FilterDomains}}<option value="{{.}}" {{if eq . $.FilterDomain}}selected{{end}}>{{.}}</option>{{end}}
</select>
<label for="app">Application</label>
<select id="app" name="app">
<option value="">All applications</option>
{{range .FilterApps}}<option value="{{.}}" {{if eq . $.FilterApp}}selected{{end}}>{{.}}</option>{{end}}
</select>
<button type="submit">Filter</button>
</form>
</div>
<div class="card">
{{template "deliveries_rows" .}}
</div>
{{end}}
@@ -0,0 +1,31 @@
{{define "deliveries_rows"}}
<div id="deliveries-rows" data-poll hx-get="/deliveries/rows?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.Page}}"
hx-trigger="load" hx-swap="outerHTML">
{{if .Rows}}
<table>
<thead>
<tr><th>Time</th><th>From</th><th>To</th><th>Subject</th><th>Status</th><th></th></tr>
</thead>
<tbody>
{{range .Rows}}
<tr>
<td class="time muted">{{.CreatedAt.Format "2006-01-02 15:04:05"}}</td>
<td>{{.From}}</td>
<td>{{.To}}</td>
<td class="subject"><span title="{{.Subject}}">{{.Subject}}</span></td>
<td>{{.Status}}</td>
<td class="actions"><a href="/deliveries/{{.ID}}?domain={{$.FilterDomain}}&app={{$.FilterApp}}&p={{$.Page}}">Details</a></td>
</tr>
{{end}}
</tbody>
</table>
<p class="muted">
Page {{.Page}}{{if .LastPage}} of {{.LastPage}}{{end}}
{{if .HasPrev}} &middot; <a href="/deliveries?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.PrevPage}}">&larr; Newer</a>{{end}}
{{if .HasNext}} &middot; <a href="/deliveries?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.NextPage}}">Older &rarr;</a>{{end}}
</p>
{{else}}
<p class="muted">No messages logged yet.</p>
{{end}}
</div>
{{end}}
+124
View File
@@ -0,0 +1,124 @@
{{/* One message's page. It takes the whole column rather than the reading
measure (see the "wide" block in layout.html): the two cards below stand
side by side, and under them are raw mail.log lines, which are long by
nature and were the reason the send log opted out too. */}}
{{define "wide"}}wide{{end}}
{{define "content"}}
{{/* The subject is the page's name — it is what the message was about, and
what an operator arriving from the log is holding in their head. The route
under it is the other half of the identity: who it was from and who it was
for, with the outcome badged on the end so the answer is on the first line
of the page rather than somewhere in the cards below. */}}
<h1 class="subject">{{if .Row.Subject}}{{.Row.Subject}}{{else}}(no subject){{end}}</h1>
<p class="route">
<span class="addr">{{.Row.From}}</span>
<span class="arrow" aria-hidden="true">&rarr;</span>
<span class="addr">{{if .Row.To}}{{.Row.To}}{{else}}—{{end}}</span>
<span class="st st-{{.Level}}">{{.Row.Status}}</span>
</p>
<a class="back" href="{{.BackURL}}">&larr; Back to deliveries</a>
{{/* 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
the history and the history only means anything for a known message — so
they are read together rather than one after the other (.split in
panel.css lays them down into one column when there is no room). */}}
<div class="split">
<div class="card" id="message">
<h2>Message</h2>
<p class="muted">What the journal recorded as Postfix accepted this
message. The delivery log lists only what identifies a row at a glance;
everything the table has no column for is here.</p>
<div class="facts">
<div class="fact">
<span class="fact-label">Domain</span>
<span class="fact-value">{{if .Row.Domain}}{{.Row.Domain}}{{else}}—{{end}}</span>
</div>
<div class="fact">
<span class="fact-label">Application</span>
<span class="fact-value">{{if .Row.AppLogin}}{{.Row.AppLogin}}{{else}}—{{end}}</span>
</div>
<div class="fact">
<span class="fact-label">Accepted</span>
<span class="fact-value">{{.Row.CreatedAt.Format "2006-01-02 15:04:05"}} UTC</span>
</div>
<div class="fact">
<span class="fact-label">Status reported</span>
<span class="fact-value">{{.Row.UpdatedAt.Format "2006-01-02 15:04:05"}} UTC</span>
</div>
{{/* The queue id is monospace because it is an identifier to compare
character by character against a log line, not a word to read. */}}
<div class="fact">
<span class="fact-label">Queue id</span>
<span class="fact-value mono">{{if .Row.QueueID}}{{.Row.QueueID}}{{else}}—{{end}}</span>
</div>
<div class="fact">
<span class="fact-label">Journal id</span>
<span class="fact-value mono">{{.Row.ID}}</span>
</div>
</div>
{{if not .Row.QueueID}}
<p class="muted">A message with no queue id was refused before Postfix
queued it — under a level-2 rate limit — so it has no delivery attempt to
show.</p>
{{end}}
</div>
<div class="card" id="history">
<h2>History</h2>
<p class="muted">A row is written when the message is accepted and updated
once when Postfix reports the attempt for this recipient, so these are the
two moments the journal knows about — not a trace of the SMTP conversation,
which is in the delivery log below.</p>
<ol class="timeline">
{{range .Events}}
<li class="event lvl-{{.Level}}{{if .At.IsZero}} pending{{end}}">
<p class="event-time">{{if .At.IsZero}}not yet{{else}}{{.At.Format "2006-01-02 15:04:05"}} UTC{{end}}</p>
<p class="event-title"><span class="st st-{{.Level}}">{{.Status}}</span> {{.Title}}</p>
<p class="event-detail muted">{{.Detail}}</p>
</li>
{{end}}
</ol>
</div>
</div>
{{/* Under both, at the column's full width: what Postfix itself wrote. The
queue id used to be printed on this page as something to go and search the
system log for by hand — this is that search, already done. */}}
<div class="card" id="delivery-log">
<h2>Delivery log</h2>
<p class="muted">The lines Postfix wrote about this message in
<code>mail.log</code>, oldest first — the connection to the receiving server,
its reply, and the status that reply was filed as. Only lines carrying this
message's queue id are shown; the whole log is on the
<a href="/system-log">System log</a> page.</p>
{{if .LogRows}}
{{/* Two columns, the way the send log itself is a table: the times down one
edge are what the message's pace is read off — the seconds between the
connection and the reply — and lining them up is what makes that
readable. A line whose head was not a timestamp keeps its whole text in
the second column and leaves the first empty. */}}
<table class="log">
<thead>
<tr><th>Time</th><th>Message</th></tr>
</thead>
<tbody>
{{range .LogRows}}
<tr>
<td class="time muted">{{if .Time}}{{.Time}}{{else}}—{{end}}</td>
<td class="log-text">{{.Text}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="muted">{{.LogNote}}</p>
{{end}}
</div>
{{end}}
@@ -0,0 +1,24 @@
{{define "content"}}
<h1>Delete {{.Domain.Name}}</h1>
<a class="back" href="/domains/{{.Domain.ID}}">&larr; Back to {{.Domain.Name}}</a>
<div class="card">
<h2>Confirm deletion</h2>
<p>You are about to delete <strong>{{.Domain.Name}}</strong>. This will:</p>
<ul>
<li>permanently delete its DKIM signing key;</li>
<li>delete
{{if eq .Domain.AppCount 0}}its (currently none) bound applications{{else}}
<strong>all {{.Domain.AppCount}} bound application(s)</strong>, including
their SASL credentials and sender bindings{{end}};</li>
<li>reload OpenDKIM so the domain is no longer signed.</li>
</ul>
<p class="muted">This cannot be undone. The published DKIM DNS record becomes
invalid; remove it from DNS if you do not plan to re-add the domain.</p>
<form method="post" action="/domains/{{.Domain.ID}}/delete">
<button type="submit" class="danger">Delete {{.Domain.Name}}</button>
</form>
</div>
{{end}}
@@ -0,0 +1,381 @@
{{define "content"}}
<h1>{{.Domain.Name}}</h1>
<a class="back" href="/domains">&larr; All domains</a>
{{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">This password is shown <strong>once only</strong> and is 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}}
<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>
<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>
<label>DKIM <span class="st st-{{.DNS.DKIM.Status}}">{{.DNS.DKIM.Status}}</span></label>
<p class="{{if eq .DNS.DKIM.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DKIM.Detail}}</p>
{{if .DNS.DKIM.Records}}<span class="code">{{range .DNS.DKIM.Records}}{{.}}
{{end}}</span>{{end}}
<label>SPF <span class="st st-{{.DNS.SPF.Status}}">{{.DNS.SPF.Status}}</span></label>
<p class="{{if eq .DNS.SPF.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.SPF.Detail}}</p>
{{if .DNS.SPF.Records}}<span class="code">{{range .DNS.SPF.Records}}{{.}}
{{end}}</span>{{end}}
<p class="muted">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>
<label>DMARC <span class="st st-{{.DNS.DMARC.Status}}">{{.DNS.DMARC.Status}}</span></label>
<p class="{{if eq .DNS.DMARC.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DMARC.Detail}}</p>
{{if .DNS.DMARC.Records}}<span class="code">{{range .DNS.DMARC.Records}}{{.}}
{{end}}</span>{{end}}
{{if .DNS.DMARCReportAuth.Status}}
<label>Report authorization <span class="st st-{{.DNS.DMARCReportAuth.Status}}">{{.DNS.DMARCReportAuth.Status}}</span></label>
<p class="{{if eq .DNS.DMARCReportAuth.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DMARCReportAuth.Detail}}</p>
{{if .DNS.DMARCReportAuth.Records}}<span class="code">{{range .DNS.DMARCReportAuth.Records}}{{.}}
{{end}}</span>{{end}}
{{end}}
<form class="inline" method="post" action="/domains/{{.Domain.ID}}/dns-recheck">
<button type="submit">Re-check</button>
</form>
</div>
<div 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>
<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>
</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>
</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>
{{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>
<!-- 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. -->
<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>
<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-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}}
{{end}}{{$a}}{{end}}</textarea>
</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>
</li>
{{end}}
</ul>
{{else}}
<p class="muted">No applications yet. Add one above to get started.</p>
{{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>
<p class="muted">Status:
{{if .DomainHasRL}}<strong>active</strong>{{else}}inactive (level-1 only){{end}}.</p>
<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>
<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>
<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="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>
{{end}}
@@ -0,0 +1,24 @@
{{/* Password fields shared by the full-backup and domain-export forms. The
checkbox is the switch: unticked, the download keeps its historic plain
form (.tar.gz / .json); ticked, the file is sealed in a password-encrypted
envelope (.spbk = SelfPost backup, .spde = SelfPost domain export). The
fields start hidden and are revealed by panel.js — with JavaScript blocked
they are simply always visible, and the server still decides from the
checkbox alone. */}}
{{define "encryptfields"}}
<div class="encrypt">
<label class="check">
<input type="checkbox" name="encrypt" value="1" data-encrypt-toggle>
<span>Encrypt with a password</span>
</label>
<div class="encrypt-fields" data-encrypt-fields>
<label for="encpw">Password</label>
<input id="encpw" name="password" type="password" autocomplete="new-password"
minlength="{{.MinPwLen}}" placeholder="at least {{.MinPwLen}} characters">
<label for="encpw2">Repeat password</label>
<input id="encpw2" name="password_confirm" type="password" autocomplete="new-password">
<p class="muted">Keep this password: without it the file cannot be opened,
and SelfPost does not store it anywhere.</p>
</div>
</div>
{{end}}
+122
View File
@@ -0,0 +1,122 @@
{{define "layout.html"}}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}}</title>
{{/* The tab icon is the stamp's small-size variant — the initials rather than
the two-line wordmark, which stops resolving below 32px (the thresholds are
recorded in docs/assets/selfpost-proof.html). The PNG is declared first and
the SVG second: a browser takes the last icon whose type it understands, so
vector wins wherever it is supported and the raster catches the rest. */}}
<link rel="icon" href="/static/favicon.png" sizes="32x32">
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/static/panel.css">
{{/* Unless told otherwise, htmx injects a stylesheet element of its own into
the head for the request-indicator classes. The panel uses no
hx-indicator, and that injected element would be the one thing the
Content-Security-Policy has to make an exception for, so it
is switched off here. */}}
<meta name="htmx-config" content='{"includeIndicatorStyles":false}'>
<script src="/static/htmx.min.js" defer></script>
<script src="/static/panel.js" defer></script>
</head>
<body>
{{/* Two columns: the navigation on the left and the page beside it, centred
together (see .shell in panel.css). The signed-out pages have no navigation
and the shell holds their card alone. */}}
<div class="shell">
{{if .User}}{{template "nav" .}}{{end}}
{{/* The page name travels onto <main> as a class so the stylesheet can size a
page to what it holds — the signed-out pages are narrower than the rest
(see main.page-* in panel.css). It is .Active, already carried for the
navigation, rather than a second field every handler would have to
remember to set. The "wide" block beside it is how a page made of data
asks for the whole column instead of the reading measure; it is the page's
own declaration rather than a name in the stylesheet, since .Active cannot
tell the send log from a single delivery's page. */}}
<main class="page-{{.Active}} {{template "wide" .}}">
{{template "content" .}}
{{/* Appropriate Legal Notices (AGPL-3.0): copyright, how to read the licence,
where the Corresponding Source is, and that there is no warranty. Shown
on every page, including login/setup — those are interactive UIs too.
The running version stays signed-in only: it is what a backup manifest is
checked against on restore, and must not be advertised to the internet. */}}
<footer class="version">
{{if .User}}SelfPost {{.Version}} · {{end}}{{.Copyright}} ·
<a href="/license">License (AGPL-3.0)</a> ·
<a href="{{.SourceURL}}">Source</a> ·
No warranty
</footer>
</main>
</div>
</body>
</html>{{end}}
{{/* nav is the panel's navigation bar. It is rendered here, from the layout, so
every authenticated page has it without the page's own template having to
remember to include it; .Active names the current page so it is highlighted
instead of linking to itself. Unauthenticated pages (login, setup) carry no
.User and get no nav. */}}
{{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>
<div class="links">
{{if eq .Active "status"}}<span aria-current="page">{{template "icon-status"}}Status</span>{{else}}<a href="/status">{{template "icon-status"}}Status</a>{{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 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}}
</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}}
<form class="inline" method="post" action="/logout">
<button type="submit" class="danger">{{template "icon-sign-out"}}Sign out</button>
</form>
</div>
</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. */}}
{{define "wide"}}{{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
aria-hidden because the entry's own text is already the accessible name;
the icon is a landmark for the eye, not a second label. Kept as separate
templates so the nav above stays one readable line per page. */}}
{{define "icon-status"}}<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="M1.25 8.5h2.9L6.2 3.4l3.1 9.4 1.9-4.3h3.55"/></svg>{{end}}
{{define "icon-domains"}}<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="8" r="6.25"/><path d="M1.9 8h12.2"/><path d="M8 1.75c1.85 1.8 2.8 4 2.8 6.25S9.85 12.45 8 14.25C6.15 12.45 5.2 10.25 5.2 8S6.15 3.55 8 1.75Z"/></svg>{{end}}
{{define "icon-deliveries"}}<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="M14.25 1.75 1.6 6.6l5 2.05 2.05 5z"/><path d="M14.25 1.75 6.6 8.65"/></svg>{{end}}
{{define "icon-mail-queue"}}<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="M1.75 9.5h3.3l1 1.75h3.9l1-1.75h3.3v3.05a1.2 1.2 0 0 1-1.2 1.2H2.95a1.2 1.2 0 0 1-1.2-1.2z"/><path d="M1.75 9.5 3.4 3.2a1.25 1.25 0 0 1 1.2-.95h6.8a1.25 1.25 0 0 1 1.2.95l1.65 6.3"/></svg>{{end}}
{{define "icon-system-log"}}<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="M3.75 1.75h5.1l3.4 3.4v8.05a1.05 1.05 0 0 1-1.05 1.05H3.75a1.05 1.05 0 0 1-1.05-1.05V2.8a1.05 1.05 0 0 1 1.05-1.05Z"/><path d="M8.85 1.75v3.4h3.4"/><path d="M5.35 8.6h5.3M5.35 11.1h3.5"/></svg>{{end}}
{{/* A filing cabinet rather than a storage box: the box read as a box, while
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}}
{{/* 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}}
+24
View File
@@ -0,0 +1,24 @@
{{define "content"}}
{{/* The full mark, on the two pages that carry no navigation: with no bar above
it, this is the only thing telling an administrator which service just asked
them for a password. */}}
<img class="mark" src="/static/logo.svg" width="330" height="150" alt="SelfPost">
<h1>Sign in</h1>
<div class="card narrow">
{{if .SetupHint}}
<p class="muted">No administrator has been created yet. Open the one-time
setup link printed in the container log to get started.</p>
{{else}}
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<form method="post" action="/login">
<label for="username">Username</label>
<input id="username" name="username" autocomplete="username" autofocus required>
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password" required>
<button type="submit">Sign in</button>
</form>
{{end}}
</div>
{{end}}
@@ -0,0 +1,12 @@
{{/* A table of queue ids, sizes, senders and recipients: the whole column
rather than the reading measure (see the "wide" block in layout.html). */}}
{{define "wide"}}wide{{end}}
{{define "content"}}
<h1>Mail queue</h1>
<div class="card">
<h2>Pending messages</h2>
{{template "mail_queue_body" .}}
</div>
{{end}}
@@ -0,0 +1,6 @@
{{define "mail_queue_body"}}
<div id="mail-queue-body" data-poll hx-get="/mail-queue/body" hx-trigger="load" hx-swap="outerHTML">
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<span class="code">{{if .Output}}{{.Output}}{{else}}Queue is empty.{{end}}</span>
</div>
{{end}}
+21
View File
@@ -0,0 +1,21 @@
{{define "content"}}
<img class="mark" src="/static/logo.svg" width="330" height="150" alt="SelfPost">
<h1>Create administrator</h1>
<div class="card narrow">
<p class="muted">This one-time link creates the single panel administrator.
After you submit, the link stops working for good.</p>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<form method="post" action="/setup/{{.Token}}">
<label for="username">Username</label>
<input id="username" name="username" autocomplete="username" autofocus required>
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="new-password" required>
<label for="password_confirm">Confirm password</label>
<input id="password_confirm" name="password_confirm" type="password" autocomplete="new-password" required>
<button type="submit">Create administrator</button>
</form>
</div>
{{end}}
+62
View File
@@ -0,0 +1,62 @@
{{define "content"}}
<h1>Server 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
database and reloads both daemons. Use it if you edited the files by hand,
restored a backup, or the running configuration looks out of step with the
domain and application lists. It does not touch the mail queue or the TLS
certificate, and it is safe to run at any time.</p>
<form class="inline" method="post" action="/reload">
<button type="submit">Reload configuration</button>
</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}}
@@ -0,0 +1,112 @@
{{define "status_body"}}
<div id="status-body" data-poll hx-get="/status/fragment" hx-trigger="load" hx-swap="outerHTML">
<div class="card" id="overall">
<h2>Overall <span class="st st-{{.OverallStatus}}">{{.OverallStatus}}</span></h2>
<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>
<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>
<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="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="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>
</div>
</div>
{{end}}
@@ -0,0 +1,12 @@
{{/* Raw mail.log lines, long by nature: the whole column rather than the
reading measure (see the "wide" block in layout.html). */}}
{{define "wide"}}wide{{end}}
{{define "content"}}
<h1>System log</h1>
<div class="card">
<h2>Recent log entries</h2>
{{template "system_log_body" .}}
</div>
{{end}}
@@ -0,0 +1,7 @@
{{define "system_log_body"}}
<div id="system-log-body" data-poll hx-get="/system-log/body" hx-trigger="load" hx-swap="outerHTML">
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<span class="code">{{if .Lines}}{{range .Lines}}{{.}}
{{end}}{{else}}No log lines yet.{{end}}</span>
</div>
{{end}}