Split internal/web into subpackages before domain-admin growth.
test / test (push) Has been cancelled
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:
@@ -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}}
|
||||
@@ -0,0 +1,41 @@
|
||||
{{define "content"}}
|
||||
<h1>Backup & 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}} · <a href="/deliveries?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.PrevPage}}">← Newer</a>{{end}}
|
||||
{{if .HasNext}} · <a href="/deliveries?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.NextPage}}">Older →</a>{{end}}
|
||||
</p>
|
||||
{{else}}
|
||||
<p class="muted">No messages logged yet.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -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">→</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}}">← 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}}">← 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">← 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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
Reference in New Issue
Block a user