Files
selfpost/internal/web/view/templates/domain_detail.html
T
mix 33ee08f7b6
test / test (push) Waiting to run
release: 1.8.0
Add in-panel Help: CSS-only drawer with seeded Status and domain topics,
«?» entry points on cards, and guide.md boundary for what stays in-repo.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 23:00:08 +03:00

508 lines
22 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{/* Wide so the .split pairs fill the column rather than the 48rem reading
measure (same pattern as Status). */}}
{{define "wide"}}wide{{end}}
{{/* The Host/name ‖ Type field-pair repeats for every DNS record this page
shows (DKIM, SPF, DMARC, report authorization) in both the status card
and the publishable-record cards below it — only the host and whether it
carries a Copy button change. Two variants rather than one templated
Copy flag: the DNS status card never offers Copy (its host is derived,
not something to paste), the record cards always do. */}}
{{define "host_type"}}
<div class="field-pair host-type">
<div>
<label>Host / name</label>
<span class="code">{{.}}</span>
</div>
<div class="field-type">
<label>Type</label>
<span class="code">TXT</span>
</div>
</div>
{{end}}
{{define "host_type_copy"}}
<div class="field-pair host-type">
<div>
<label>Host / name</label>
<div class="code-row">
<span class="code">{{.}}</span>
<button type="button" class="copy">Copy</button>
</div>
</div>
<div class="field-type">
<label>Type</label>
<span class="code">TXT</span>
</div>
</div>
{{end}}
{{/* Value partials pair with the two host_type variants above: field_values
is the DNS status card's raw record dump (no Copy — those values are for
comparison, not for pasting), field_value is the single publishable
value on the record cards below (always has Copy). Callers still guard
the empty case, since "no records yet" and "one blank record" read
differently. */}}
{{define "field_values"}}
<label>Value</label>
<span class="code">{{range .}}{{.}}
{{end}}</span>
{{end}}
{{define "field_value"}}
<label>Value</label>
<div class="code-row">
<span class="code">{{.}}</span>
<button type="button" class="copy">Copy</button>
</div>
{{end}}
{{define "content"}}
<h1>{{.Domain.Name}}</h1>
{{template "back_link" (back "/domains" "All domains")}}
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
{{if .RateLimitErr}}<div class="flash error">{{.RateLimitErr}}</div>{{end}}
{{if .NewCred}}
<div class="card credential" id="new-credential">
<div class="card-head">
<h2>New application password</h2>
{{template "card_help" (dict "ID" "password" "Title" "Why this is shown once")}}
</div>
<p class="muted">Shown <strong>once only</strong> and not stored. Copy it now
— if it is lost, regenerate a new one.</p>
<label>Login</label>
<div class="code-row">
<span class="code">{{.NewCred.Login}}</span>
<button type="button" class="copy">Copy</button>
</div>
<label>Password</label>
<div class="code-row">
<span class="code">{{.NewCred.Password}}</span>
<button type="button" class="copy">Copy</button>
</div>
</div>
{{end}}
{{/* Two rows of two checks (.check-cols). */}}
<div class="card" id="dns-status">
<div class="card-head">
<h2>DNS status <span class="st st-{{.DNS.Overall}}">{{.DNS.Overall}}</span></h2>
{{template "card_help" (dict "ID" "dns" "Title" "What these checks mean")}}
</div>
<p class="muted">Cached a few minutes — use <em>Re-check</em> after
publishing.</p>
<div class="check-cols">
<div class="check-col">
<label>DKIM <span class="st st-{{.DNS.DKIM.Status}}">{{.DNS.DKIM.Status}}</span></label>
{{template "host_type" .Record.Name}}
{{if .DNS.DKIM.Records}}{{template "field_values" .DNS.DKIM.Records}}{{end}}
{{if ne .DNS.DKIM.Status "ok"}}
<p class="{{if eq .DNS.DKIM.Status "unknown"}}muted{{else}}error{{end}}">{{.DNS.DKIM.Detail}}</p>
{{end}}
</div>
<div class="check-col">
<label>SPF <span class="st st-{{.DNS.SPF.Status}}">{{.DNS.SPF.Status}}</span></label>
{{template "host_type" .Domain.Name}}
{{if .DNS.SPF.Records}}{{template "field_values" .DNS.SPF.Records}}{{end}}
{{if ne .DNS.SPF.Status "ok"}}
<p class="{{if eq .DNS.SPF.Status "unknown"}}muted{{else}}error{{end}}">{{.DNS.SPF.Detail}}</p>
{{end}}
<p class="muted">Shallow check: literal address only, no <code>include:</code> /
<code>redirect=</code>.</p>
</div>
<div class="check-col">
<label>DMARC <span class="st st-{{.DNS.DMARC.Status}}">{{.DNS.DMARC.Status}}</span></label>
{{template "host_type" .DMARCName}}
{{if .DNS.DMARC.Records}}{{template "field_values" .DNS.DMARC.Records}}{{end}}
{{if ne .DNS.DMARC.Status "ok"}}
<p class="{{if eq .DNS.DMARC.Status "unknown"}}muted{{else}}error{{end}}">{{.DNS.DMARC.Detail}}</p>
{{else}}
<p class="muted">{{.DNS.DMARC.Detail}}</p>
{{end}}
</div>
<div class="check-col">
{{if .DNS.DMARCReportAuth.Status}}
<label>Report authorization <span class="st st-{{.DNS.DMARCReportAuth.Status}}">{{.DNS.DMARCReportAuth.Status}}</span></label>
{{template "host_type" .ReportAuthName}}
{{if .DNS.DMARCReportAuth.Records}}{{template "field_values" .DNS.DMARCReportAuth.Records}}{{end}}
<p class="{{if eq .DNS.DMARCReportAuth.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DMARCReportAuth.Detail}}</p>
{{else}}
<label>Report authorization</label>
<p class="muted">Not required (no external <code>rua=</code>).</p>
{{end}}
</div>
</div>
<form class="inline" method="post" action="/domains/{{.Domain.ID}}/dns-recheck">
<button type="submit">Re-check</button>
</form>
</div>
<div class="split">
<div class="card" id="dkim-spf">
<div class="card-head">
<h2>DKIM and SPF records</h2>
{{template "card_help" (dict "ID" "records" "Title" "What to publish")}}
</div>
<p class="check-col-title">DKIM</p>
{{template "host_type_copy" .Record.Name}}
{{template "field_value" .Record.Value}}
<p class="muted">Not a secret. Signed with selector
<strong>{{.Domain.DKIMSelector}}</strong>.</p>
<p class="check-col-title">SPF</p>
{{template "host_type_copy" .Domain.Name}}
{{template "field_value" .SPFExample}}
<p class="muted">Merge into an existing SPF if the domain already has one —
do not publish a second record.</p>
</div>
<div class="card" id="dmarc">
<div class="card-head">
<h2>DMARC record</h2>
{{template "card_help" (dict "ID" "dmarc" "Title" "What this record does")}}
</div>
{{template "host_type_copy" .DMARCName}}
<label>Value{{if eq .DMARCSource "settings"}} <span class="muted">(from Settings)</span>{{else if eq .DMARCSource "custom"}} <span class="muted">(custom)</span>{{else if eq .DMARCSource "none"}} <span class="muted">(no reports)</span>{{end}}</label>
<div class="code-row">
<span class="code">{{.DMARCExample}}</span>
<button type="button" class="copy">Copy</button>
</div>
{{if .SameDomainRUA}}
<p class="error">The report address is on this sending domain.{{if not .DMARCIngestEnabled}}
SelfPost does not receive inbound mail — use a mailbox on another domain.{{end}}</p>
{{end}}
{{if and .DMARCIngestEnabled .ResolvedDMARCEmail}}
<p class="muted"><a href="/dmarc/domains/{{.Domain.ID}}">View DMARC reports</a> for this domain.</p>
{{end}}
{{if .NeedsReportAuth}}
<p class="check-col-title">Report authorization</p>
{{template "host_type_copy" .ReportAuthName}}
{{template "field_value" .ReportAuthValue}}
{{end}}
<p class="muted"><code>p=none</code> does not affect delivery. Tighten to
<code>p=quarantine</code> then <code>p=reject</code> once reports look clean.
Report address is set under <a href="#domain-settings">Domain settings</a>.</p>
</div>
</div>
<div class="split">
<div class="card" id="connection">
<div class="card-head">
<h2>Connection settings</h2>
{{template "card_help" (dict "ID" "connection" "Title" "How to connect")}}
</div>
<p class="muted">Same for every domain. Authenticate with an application
login from below.</p>
<label>Server</label>
<div class="code-row">
<span class="code">{{.Hostname}}</span>
<button type="button" class="copy">Copy</button>
</div>
<label>Port and encryption</label>
<span class="code">465 — SSL/TLS (implicit){{if .SubmissionEnabled}}
587 — STARTTLS (submission){{end}}</span>
<p class="muted">Auth required on every port. The password is shown once at
create or regenerate.</p>
</div>
{{/* Create form beside connection settings, mirroring "Add a sending domain"
above the domains list. */}}
<div class="card" id="add-application">
<h2>Add an application</h2>
<form method="post" action="/domains/{{.Domain.ID}}/applications">
<label for="login">Login</label>
<input id="login" name="login" type="text" placeholder="prod-server"
autocomplete="off" autocapitalize="none" spellcheck="false"
value="{{.FormLogin}}" required>
<label for="mode">Address mode</label>
<select id="mode" name="mode" data-list-mode="{{.List}}">
<option value="{{.Wildcard}}" {{if eq .FormMode .Wildcard}}selected{{end}}>Any address of the domain</option>
<option value="{{.List}}" {{if eq .FormMode .List}}selected{{end}}>Specific addresses (list)</option>
</select>
<div data-addresses>
<label for="addresses">Addresses (one per line or comma-separated)</label>
<textarea id="addresses" name="addresses" rows="3"
placeholder="alerts@{{.Domain.Name}}">{{.FormAddrs}}</textarea>
</div>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<button type="submit">Create application</button>
</form>
<p class="muted">Password shown once. Login unique across domains; letters,
digits, '.', '-' and '_'.</p>
</div>
</div>
<div class="card" id="send-stats">
<h2>Sending statistics ({{.StatsWindowDays}} days)</h2>
<p class="muted">One message = one queue id (many recipients count once).
Level-1 refusals are not in the send log — totals under-count strict IP
limits.{{if .StatsRetentionWarning}} Send log retention is shorter than
30&nbsp;days; statistics use the last {{.StatsWindowDays}}&nbsp;days only.{{end}}</p>
<div class="field-pair">
<div>
<label>Total messages</label>
<span>{{.DomainStats.Total}}</span>
</div>
<div>
<label>Peak rate (msg/h)</label>
<span>{{.DomainStats.PeakPerHour}}</span>
</div>
<div>
<label>Average rate (msg/h)</label>
<span>{{.DomainStats.AvgPerHour}}</span>
</div>
</div>
</div>
<div class="card" id="applications">
<div class="card-head">
<h2>Applications</h2>
{{template "card_help" (dict "ID" "apps" "Title" "What an application is")}}
</div>
<p class="muted">SASL logins for this domain — wildcard (*@domain) or a fixed
address list.</p>
{{if .Apps}}
<ul class="apps">
{{range .Apps}}
<li class="app">
<p class="app-login">{{.Login}}</p>
<p class="app-addr muted">
{{if eq .AddressMode $.Wildcard}}Any address of the domain — *@{{$.Domain.Name}}
{{else}}Fixed list — {{range $i, $a := .Addresses}}{{if $i}}, {{end}}{{$a}}{{end}}{{end}}
</p>
<p class="muted app-stats">{{.Stats.Total}} msg / {{$.StatsWindowDays}}d ·
peak {{.Stats.PeakPerHour}} msg/h · avg {{.Stats.AvgPerHour}} msg/h</p>
<!-- One Edit panel (mode ‖ rate limit) opened by a checkbox and label
rather than <details>, so the button row stays intact — see
.panel-toggle in panel.css. -->
<div class="actions">
<input class="panel-toggle t-edit" id="edit-{{.ID}}" type="checkbox">
<label class="toggle for-edit" for="edit-{{.ID}}">Edit{{if .HasLimit}} <span class="st st-ok">limit</span>{{end}}</label>
<form class="inline" method="post" action="/applications/{{.ID}}/password"
data-confirm="Regenerate the password for {{.Login}}? The current password stops working immediately.">
<button type="submit">New password</button>
</form>
<form class="inline" method="post" action="/applications/{{.ID}}/delete"
data-confirm="Delete application {{.Login}}? Its credentials stop working immediately.">
<button type="submit" class="danger">Delete</button>
</form>
<div class="panel panel-edit">
<div class="check-cols">
<div class="check-col">
<p class="check-col-title">Address mode</p>
<p class="muted">Which From addresses this application may use.</p>
<form method="post" action="/applications/{{.ID}}/mode">
<select name="mode" data-list-mode="{{$.List}}" aria-label="Address mode">
<option value="{{$.Wildcard}}" {{if eq .AddressMode $.Wildcard}}selected{{end}}>Any address of the domain</option>
<option value="{{$.List}}" {{if eq .AddressMode $.List}}selected{{end}}>Specific addresses (list)</option>
</select>
<div data-addresses>
<label>Addresses (one per line or comma-separated)</label>
<textarea name="addresses" rows="3" placeholder="alerts@{{$.Domain.Name}}">{{range $i, $a := .Addresses}}{{if $i}}
{{end}}{{$a}}{{end}}</textarea>
</div>
<button type="submit">Save mode</button>
</form>
</div>
<div class="check-col">
<p class="check-col-title">Optional trusted-IP override
{{if .HasLimit}}<span class="st st-ok">active</span>{{else}}<span class="st st-unknown">inactive</span>{{end}}</p>
<p class="muted">One per line or comma-separated.</p>
<form id="rl-{{.ID}}" method="post" action="/applications/{{.ID}}/ratelimit">
<textarea name="allowed_ips" rows="1" placeholder="203.0.113.10"
aria-label="Trusted client IPs">{{.IPsText}}</textarea>
<p class="muted">These IPs get a higher ceiling than the domain
(≤ level&nbsp;1) and skip the domain check; everyone else uses
the domain level-2 limit{{if $.DomainHasRL}} ({{$.DomainRLMaxNum}}){{end}}
if set, otherwise level&nbsp;1.</p>
<label for="mode-{{.ID}}">Limit mode</label>
<select id="mode-{{.ID}}" name="mode" data-ratelimit-mode>
<option value="manual" {{if not .IsAuto}}selected{{end}}>Manual</option>
<option value="auto" {{if .IsAuto}}selected{{end}}>Auto (from statistics)</option>
</select>
<div data-manual-fields>
<div class="field-pair">
<div>
<label>Message limit (max {{$.L1Messages}})</label>
<input name="max_messages" type="number" min="1" max="{{$.L1Messages}}"
value="{{.MaxText}}" placeholder="{{$.L1Messages}}">
</div>
<div>
<label>Window (seconds)</label>
<input name="window_seconds" type="number" min="1" value="{{.WindowVal}}">
</div>
</div>
</div>
<div data-auto-fields>
<label for="mult-{{.ID}}">Multiplier ({{$.MinAutoMultiplier}}{{$.MaxAutoMultiplier}}, default {{$.DefaultAutoMultiplier}})</label>
<input id="mult-{{.ID}}" name="auto_multiplier" type="number" min="{{$.MinAutoMultiplier}}"
max="{{$.MaxAutoMultiplier}}" step="0.1" value="{{.AutoMultiplier}}">
<p class="muted">Computed limit: <strong>{{.MaxText}}</strong> messages /
{{$.L1Window}}&nbsp;s (level-1 window). Zero traffic keeps auto inactive until
messages are sent.{{if .AutoUpdated}} Last recalculated {{.AutoUpdated}}.{{end}}
Peak {{.Stats.PeakPerHour}} msg/h shown for comparison.</p>
</div>
</form>
<div class="panel-buttons">
<button type="submit" form="rl-{{.ID}}">Save limit</button>
{{if .IsAuto}}
<form class="inline" method="post" action="/applications/{{.ID}}/ratelimit/recalc">
<button type="submit">Recalculate now</button>
</form>
{{end}}
{{if .HasLimit}}
<form class="inline" method="post" action="/applications/{{.ID}}/ratelimit"
data-confirm="Remove the rate limit for {{.Login}}? The domain limit (or level 1) will apply.">
<input type="hidden" name="clear" value="1">
<button type="submit" class="danger">Remove limit</button>
</form>
{{end}}
</div>
</div>
</div>
</div>
</div>
</li>
{{end}}
</ul>
{{else}}
<p class="muted">No applications yet. Add one above to get started.</p>
{{end}}
</div>
<div class="card" id="domain-settings">
<div class="card-head">
<h2>Domain settings</h2>
{{template "card_help" (dict "ID" "domain-settings" "Title" "Reports and rate limits")}}
</div>
<div class="check-cols check-cols-rows">
<div class="check-col">
<p class="check-col-title">DMARC reports</p>
<p class="muted">Default comes from <a href="/settings">Settings</a>;
override per domain here.</p>
<div class="check-col-fields">
<form id="dmarc-domain" method="post" action="/domains/{{.Domain.ID}}/dmarc">
<label for="dmarc_rua_mode">Aggregate reports (rua=)</label>
<select id="dmarc_rua_mode" name="dmarc_rua_mode" data-custom-mode="custom">
<option value="inherit"{{if eq .DMARCRuaMode "inherit"}} selected{{end}}>Same as Settings{{if .ProfileDMARCEmail}} ({{.ProfileDMARCEmail}}){{end}}</option>
{{if .DMARCIngestEnabled}}<option value="hosted"{{if eq .DMARCRuaMode "hosted"}} selected{{end}}>SelfPost hosted ({{.HostedDMARCEmail}})</option>{{end}}
<option value="none"{{if eq .DMARCRuaMode "none"}} selected{{end}}>No aggregate reports</option>
<option value="custom"{{if eq .DMARCRuaMode "custom"}} selected{{end}}>Custom address</option>
</select>
<div data-custom-address>
<label for="dmarc_rua_email">Custom report address</label>
<input id="dmarc_rua_email" name="dmarc_rua_email" type="email"
autocapitalize="none" spellcheck="false" value="{{.DMARCRuaCustom}}"
placeholder="reports@your-mail-domain.com">
</div>
</form>
</div>
<div class="check-col-actions">
<button type="submit" form="dmarc-domain">Save DMARC report settings</button>
</div>
</div>
<div class="check-col">
<p class="check-col-title">Level-2 rate limit
{{if .DomainHasRL}}<span class="st st-ok">active</span>{{else}}<span class="st st-unknown">inactive</span>{{end}}</p>
<p class="muted">Level&nbsp;1 backstop: {{.L1Messages}} messages / {{.L1Window}}s — <a href="/settings#rate-limits">Settings</a>.</p>
<div class="check-col-fields">
<form id="rl-domain" method="post" action="/domains/{{.Domain.ID}}/ratelimit">
<label for="d_mode">Limit mode</label>
<select id="d_mode" name="mode" data-ratelimit-mode>
<option value="manual" {{if not .DomainRLAuto}}selected{{end}}>Manual</option>
<option value="auto" {{if .DomainRLAuto}}selected{{end}}>Auto (from statistics)</option>
</select>
<div data-manual-fields>
<div class="field-pair">
<div>
<label for="d_max">Message limit (max {{.L1Messages}})</label>
<input id="d_max" name="max_messages" type="number" min="1" max="{{.L1Messages}}"
value="{{.DomainRLMax}}" placeholder="{{.L1Messages}}">
</div>
<div>
<label for="d_win">Window (seconds)</label>
<input id="d_win" name="window_seconds" type="number" min="1" value="{{.DomainRLWin}}">
</div>
</div>
<p class="muted">Applies to every client IP on this domain. Leave the
message limit empty to use level&nbsp;1 only.</p>
</div>
<div data-auto-fields>
<label for="d_mult">Multiplier ({{.MinAutoMultiplier}}{{.MaxAutoMultiplier}}, default {{.DefaultAutoMultiplier}})</label>
<input id="d_mult" name="auto_multiplier" type="number" min="{{.MinAutoMultiplier}}"
max="{{.MaxAutoMultiplier}}" step="0.1" value="{{.DomainRLMultiplier}}">
<p class="muted">Computed limit: <strong>{{.DomainRLMax}}</strong> messages /
{{.L1Window}}&nbsp;s (level-1 window). Zero traffic keeps auto inactive until
messages are sent.{{if .DomainRLAutoUpdated}} Last recalculated {{.DomainRLAutoUpdated}}.{{end}}
Peak {{.DomainStats.PeakPerHour}} msg/h shown for comparison.</p>
</div>
</form>
</div>
<div class="check-col-actions">
<button type="submit" form="rl-domain">Save limit</button>
{{if .DomainRLAuto}}
<form class="inline" method="post" action="/domains/{{.Domain.ID}}/ratelimit/recalc">
<button type="submit">Recalculate now</button>
</form>
{{end}}
{{if .DomainHasRL}}
<form class="inline" method="post" action="/domains/{{.Domain.ID}}/ratelimit"
data-confirm="Remove the domain rate limit? Only the global level-1 limit will apply.">
<input type="hidden" name="clear" value="1">
<button type="submit" class="danger">Remove limit</button>
</form>
{{end}}
</div>
</div>
</div>
</div>
<div class="split">
<div class="card" id="export">
<div class="card-head">
<h2>Export domain</h2>
{{template "card_help" (dict "ID" "export" "Title" "What the file contains")}}
</div>
<p class="muted"><strong>Secret file</strong> — transfer securely, or encrypt
below as <code>.spde</code>.</p>
{{if .ExportErr}}<p class="error">{{.ExportErr}}</p>{{end}}
<form method="post" action="/domains/{{.Domain.ID}}/export">
{{template "encryptfields" .}}
<button type="submit">Export domain</button>
</form>
</div>
<div class="card" id="danger">
<h2>Danger zone</h2>
<p class="muted">Deletes the DKIM key and every application on this domain.</p>
<a class="danger" href="/domains/{{.Domain.ID}}/delete">Delete domain</a>
</div>
</div>
{{end}}