panel-redesign: Outbound domains, domain, settings, application on the kit

Stage 2, step 3. Written by Sonnet, reviewed by Opus.

  - Six pages named after their mockups: out-domains, out-domain,
    out-domain-settings, out-app, out-app-created, out-domain-delete,
    with typed page data (view/outbound.go, outbound_app.go), fixtures
    and tests. The engine pages dashboard, domain_detail and
    domain_delete are gone with their templates and are off
    legacy_pages.txt. GET .../settings has its own handler.
  - Handlers pass typed data; routes, POST targets, field names,
    validation, authorization (both roles, another tenant's id is a 404)
    and Cache-Control: no-store on the password page are unchanged.
  - Kit, attribute only: Action.Confirm writes data-confirm on a POST
    action's form (panel.js already reads it).
  - test/e2e/panel_client.go scrapes the new markup (copy fields, the
    application row's Edit link). Not run: no Docker here.

Fixes found on the way
  - Report address of a domain that follows another user's default: the
    select had one "inherit" option, selected, so saving the form
    untouched moved the domain to whoever saved it. It now offers that
    user's default as "keep" (selected, saves nothing) and "My default
    instead" as the change, as the mockup draws it; "keep" on a domain
    that does not follow someone else is refused.
  - panel.js: syncShowWhen stopped at the first matching rule, so the
    auto-limit fields were never toggled with the limit mode. Every
    matching rule applies now.

Differences from the mockups, for the owner:
  - application rows carry Edit only. New password and Delete are POSTs,
    and the kit has no link-styled POST control for a table row; both are
    in the application page's head with a confirmation. Drawing them in
    the row needs a design: change (as Sign out was);
  - the application login is read-only on edit (the handler refuses a
    rename); the submit says Create application on the empty form;
  - multiplier bounds are the store's 1.5-5, default 2.5;
  - "Recalculate" buttons exist where the stored limit is automatic;
  - a domain administrator has no add box and no Delete; Delete domain is
    global-only on the domain page and on settings;
  - a fourth record, Report authorization, appears when the report
    address is on another domain; record states missing / weak / not
    checked are tags with the explanation under the value;
  - refusals are the kit flash above the first box, and those of the
    settings forms now show on the settings page; successful saves still
    redirect to the domain page;
  - the export checkbox is ticked by default, as drawn.

Evidence
  - docs/assets/panel-redesign/evidence/out-domains.png, out-domain.png,
    out-domain-settings.png, out-app.png, out-app-created.png,
    out-domain-delete.png: 1440 px wide, headless Edge, real templates
    served locally with their fixtures - NOT from the container (no
    Docker on this machine). Taken before the report-address fix, which
    does not change what the fixture renders.
  - panel/audit.js on the six pages at 1440 and 1280 (text inside
    [hidden] blocks skipped): 866 texts checked, problems: [].
  - go test -count=1 ./internal/web/...
      ok  internal/web
      ok  internal/web/auth
      ok  internal/web/handlers
      ok  internal/web/validate
      ok  internal/web/view

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-10 00:49:20 +03:00
co-authored by Claude Opus 5.5
parent 5c654723c2
commit acdfaaecd7
37 changed files with 2767 additions and 1010 deletions
+1 -1
View File
@@ -34,7 +34,7 @@
{{/* action is a button in a page head or a box foot (input: Action): a link, or
a POST form with one submit button when Post is set — a state change is
never a link. */}}
{{define "action"}}{{if .Post}}<form method="post" action="{{.Post}}"><button type="submit" class="button{{if .Primary}} is-primary{{end}}{{if .Danger}} is-danger is-light{{end}}">{{if .Icon}}<i class="ti {{.Icon}}"></i>{{end}}{{.Label}}</button></form>{{else}}<a class="button{{if .Primary}} is-primary{{end}}{{if .Danger}} is-danger is-light{{end}}" href="{{.Href}}">{{if .Icon}}<i class="ti {{.Icon}}"></i>{{end}}{{.Label}}</a>{{end}}{{end}}
{{define "action"}}{{if .Post}}<form method="post" action="{{.Post}}"{{if .Confirm}} data-confirm="{{.Confirm}}"{{end}}><button type="submit" class="button{{if .Primary}} is-primary{{end}}{{if .Danger}} is-danger is-light{{end}}">{{if .Icon}}<i class="ti {{.Icon}}"></i>{{end}}{{.Label}}</button></form>{{else}}<a class="button{{if .Primary}} is-primary{{end}}{{if .Danger}} is-danger is-light{{end}}" href="{{.Href}}">{{if .Icon}}<i class="ti {{.Icon}}"></i>{{end}}{{.Label}}</a>{{end}}{{end}}
{{/* ---- page head and postmark */}}
@@ -1,48 +0,0 @@
{{define "content"}}
<h1>Domains</h1>
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
{{if .IsGlobal}}
<div class="card">
<h2>Add a sending domain</h2>
<form method="post" action="/outbound/domains">
<label for="name">Domain</label>
<div class="input-row">
<input id="name" name="name" type="text" placeholder="example.com"
autocomplete="off" autocapitalize="none" spellcheck="false"
value="{{.FormName}}" autofocus required>
<button type="submit">Add domain</button>
</div>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
</form>
</div>
{{end}}
<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="/outbound/domains/{{.ID}}">{{.Name}}</a></td>
<td><a class="st st-{{.DNS}}" href="/outbound/domains/{{.ID}}#dns-status">{{.DNS}}</a></td>
<td class="muted">{{.DKIMSelector}}</td>
<td>{{.AppCount}}</td>
{{if $.IsGlobal}}<td class="actions"><a class="danger" href="/outbound/domains/{{.ID}}/delete">Delete</a></td>{{else}}<td></td>{{end}}
</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}}
@@ -1,24 +0,0 @@
{{define "content"}}
<h1>Delete {{.Domain.Name}}</h1>
{{template "back_link" (back (printf "/outbound/domains/%d" .Domain.ID) (printf "Back to %s" .Domain.Name))}}
<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="/outbound/domains/{{.Domain.ID}}/delete">
<button type="submit" class="danger">Delete {{.Domain.Name}}</button>
</form>
</div>
{{end}}
@@ -1,513 +0,0 @@
{{/* 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 "/outbound/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="/outbound/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="/outbound/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="/outbound/domains/{{.Domain.ID}}/applications/new">
<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="/outbound/domains/{{$.Domain.ID}}/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="/outbound/domains/{{$.Domain.ID}}/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">
{{/* The application form is one POST: sender, client IPs and rate
limit are saved together by the one button under the columns. */}}
<form id="app-{{.ID}}" method="post" action="/outbound/domains/{{$.Domain.ID}}/applications/{{.ID}}">
<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>
<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>
</div>
<div class="check-col">
<p class="check-col-title">Client IP allow-list
{{if .AuthIPRestrict}}<span class="st st-ok">active</span>{{else}}<span class="st st-unknown">off</span>{{end}}</p>
<p class="muted">When enabled, only these addresses may submit mail as
this application; a login from anywhere else is refused with a 4xx at
<code>MAIL FROM</code>. When off, any client IP is allowed. Like the
level-2 rate limit this runs in the journal-milter and is fail-open —
it is a sending control, not an authentication boundary.</p>
<label class="check-row">
<input type="checkbox" name="auth_ip_restrict" value="1"
{{if .AuthIPRestrict}}checked{{end}}>
Restrict to listed IPs
</label>
<textarea name="auth_allowed_ips" rows="2" placeholder="203.0.113.10"
aria-label="Allowed client IPs">{{.AuthIPsText}}</textarea>
</div>
<div class="check-col">
<p class="check-col-title">Level-2 rate limit
{{if .HasLimit}}<span class="st st-ok">active</span>{{else}}<span class="st st-unknown">inactive</span>{{end}}</p>
<p class="muted">Overrides the domain limit for this application — the
ceiling may be <em>higher or lower</em> than the domain setting
(≤ level&nbsp;1). When unset, the domain limit{{if $.DomainHasRL}}
({{$.DomainRLMaxNum}}){{end}} or level&nbsp;1 applies.</p>
<label for="mode-{{.ID}}">Limit mode</label>
<select id="mode-{{.ID}}" name="rl_mode" data-ratelimit-mode>
<option value="domain" {{if eq .RLMode "domain"}}selected{{end}}>Use the domain limit</option>
<option value="manual" {{if eq .RLMode "manual"}}selected{{end}}>Manual</option>
<option value="auto" {{if eq .RLMode "auto"}}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>
</div>
</div>
</form>
<div class="panel-buttons">
<button type="submit" form="app-{{.ID}}">Save application</button>
{{if .IsAuto}}
<form class="inline" method="post" action="/outbound/domains/{{$.Domain.ID}}/applications/{{.ID}}/ratelimit/recalc">
<button type="submit">Recalculate now</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="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="/account#dmarc">Account</a>;
override per domain here.</p>
<div class="check-col-fields">
<form id="dmarc-domain" method="post" action="/outbound/domains/{{.Domain.ID}}/settings/reports">
<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 {{if .IsGlobal}} — <a href="/server/settings#rate-limits">Settings</a>{{end}}.</p>
<div class="check-col-fields">
<form id="rl-domain" method="post" action="/outbound/domains/{{.Domain.ID}}/settings/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="/outbound/domains/{{.Domain.ID}}/settings/ratelimit/recalc">
<button type="submit">Recalculate now</button>
</form>
{{end}}
{{if .DomainHasRL}}
<form class="inline" method="post" action="/outbound/domains/{{.Domain.ID}}/settings/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="/outbound/domains/{{.Domain.ID}}/settings/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="/outbound/domains/{{.Domain.ID}}/delete">Delete domain</a>
</div>
</div>
{{end}}
+71
View File
@@ -0,0 +1,71 @@
{{/* The application form (mockup out-app.html): who the login may send as, which
client IPs may use it and its own rate limit, in one form with one button.
The same form, empty, adds an application; the three parts are checked and
saved together or not at all. */}}
{{define "content"}}
{{template "page_head" .Head}}
{{if .Flash}}{{template "flash" .Flash}}{{end}}
<form class="sp-form" method="post" action="{{.Action}}">
<div class="columns">
<div class="column is-4">
{{template "box_open" .Sender}}
{{template "body_open"}}
<div class="field"><label class="label" for="login">Login</label>
<div class="control"><input class="input sp-mono" id="login" name="login" value="{{.Login}}"{{if .LoginFixed}} readonly{{end}} autocomplete="off" autocapitalize="none" spellcheck="false" required></div>
<p class="help">{{template "inline" .LoginHelp}}</p></div>
<div class="field"><label class="label" for="amode">May send as</label>
<div class="control"><div class="select is-fullwidth"><select id="amode" name="mode" data-list-mode="list">{{$mode := .Mode}}{{range .Modes}}
<option value="{{.Value}}"{{if eq .Value $mode}} selected{{end}}>{{.Label}}</option>{{end}}
</select></div></div></div>
<div data-addresses>
<div class="field"><label class="label" for="addresses">Addresses</label>
<div class="control"><textarea class="textarea" id="addresses" name="addresses" rows="3" placeholder="{{.Placeholder}}">{{.Addresses}}</textarea></div>
<p class="help">One per line. Used only with a list.</p></div>
</div>
{{template "body_close"}}
{{template "box_close"}}
</div>
<div class="column is-4">
{{template "box_open" .IPs}}
{{template "body_open"}}
<div class="field"><label class="checkbox"><input type="checkbox" name="auth_ip_restrict" value="1"{{if .IPRestrict}} checked{{end}}> Restrict to listed IPs</label></div>
<div class="field"><label class="label" for="ips">Allowed client IPs</label>
<div class="control"><textarea class="textarea sp-mono" id="ips" name="auth_allowed_ips" rows="3" placeholder="203.0.113.10">{{.AllowedIPs}}</textarea></div>
<p class="help">A login from anywhere else is refused with a 4xx at <code>MAIL FROM</code>. A sending control, fail-open — not an authentication boundary.</p></div>
{{template "body_close"}}
{{template "box_close"}}
</div>
<div class="column is-4">
{{template "box_open" .Limit}}
{{template "body_open"}}
<div class="field"><label class="label" for="rmode">Limit mode</label>
<div class="control"><div class="select is-fullwidth"><select id="rmode" name="rl_mode" data-ratelimit-mode>{{$limit := .LimitMode}}{{range .LimitModes}}
<option value="{{.Value}}"{{if eq .Value $limit}} selected{{end}}>{{.Label}}</option>{{end}}
</select></div></div></div>
<div data-manual-fields>
<div class="sp-pair">
<div class="field"><label class="label" for="max">Messages (max {{.Rate.L1Messages}})</label><div class="control"><input class="input" id="max" name="max_messages" type="number" min="1" max="{{.Rate.L1Messages}}" value="{{.MaxMessages}}" placeholder="{{.Rate.L1Messages}}"></div></div>
<div class="field"><label class="label" for="win">Window, s</label><div class="control"><input class="input" id="win" name="window_seconds" type="number" min="1" value="{{.Window}}"></div></div>
</div>
</div>
<div data-auto-fields>
<div class="field"><label class="label" for="mult">Multiplier ({{.Rate.MinMultiplier}}–{{.Rate.MaxMultiplier}}, default {{.Rate.DefaultMultiplier}})</label>
<div class="control"><input class="input" id="mult" name="auto_multiplier" type="number" min="{{.Rate.MinMultiplier}}" max="{{.Rate.MaxMultiplier}}" step="0.1" value="{{.Multiplier}}"></div>
<p class="help">{{template "inline" .Rate.AutoHelp}}</p></div>
</div>
<p class="help">{{template "inline" .LimitHelp}}</p>
{{template "body_close"}}
{{template "box_close"}}
</div>
</div>
<div class="buttons">
<button type="submit" class="button is-primary">{{.Submit}}</button>
{{if .Recalc}}<button type="submit" class="button" formaction="{{.Recalc}}">Recalculate rate limit</button>{{end}}
<a class="button is-text" href="{{.Cancel}}">Cancel</a>
</div>
</form>
{{end}}
@@ -0,0 +1,26 @@
{{/* The password of an application, shown once (mockup out-app-created.html): the
response to the POST that created the application or issued a new password,
never stored and never cached. It has no address of its own. */}}
{{define "content"}}
{{template "page_head" .Head}}
<div class="columns">
<div class="column is-7">
{{template "box_open" .Credential}}
{{template "record_open"}}
{{template "copy_field" .Login}}
{{template "copy_field" .Password}}
{{template "record_close"}}
{{template "box_foot" .Done}}
{{template "box_close"}}
</div>
<div class="column is-5">
{{template "box_open" .Connect}}
{{template "body_open"}}
{{template "facts" .Facts}}
{{template "body_close"}}
{{template "box_close"}}
</div>
</div>
{{end}}
@@ -0,0 +1,41 @@
{{/* One outbound domain (mockup out-domain.html): the records to publish and
what DNS says about them, the applications that send as the domain, and how
to connect. The side menu leads to the three boxes, to the pages about the
same domain and to its settings. */}}
{{define "content"}}
{{template "page_head" .Head}}
{{if .Flash}}{{template "flash" .Flash}}{{end}}
<div class="columns">
<div class="column is-3">
{{template "side_menu" .Menu}}
</div>
<div class="column is-9">
{{template "box_open" .DNS}}
{{range .Records}}{{template "dns_record" .}}
{{end}}
{{template "box_close"}}
{{template "box_open" .Apps}}
{{if .AppRows}}
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Login</th><th>May send as</th><th>{{.Days}} days</th><th>Limits</th><th></th></tr></thead>
<tbody>{{range .AppRows}}
<tr><td class="sp-mono"><strong>{{.Login}}</strong></td><td>{{range $i, $a := .Senders}}{{if $i}}, {{end}}{{wbr_at $a}}{{end}}</td><td class="sp-muted">{{.Activity}}</td><td>{{range $i, $t := .Limits}}{{if $i}} {{end}}{{template "tag" $t}}{{end}}</td>
<td class="sp-actions"><a href="{{.Edit}}">Edit</a></td></tr>{{end}}
</tbody>
</table></div>
{{else}}
{{template "empty_state" .AppsEmpty}}
{{end}}
{{template "box_close"}}
{{template "box_open" .Connection}}
{{template "body_open"}}
{{template "facts" .ConnFacts}}
{{template "body_close"}}
{{template "box_close"}}
</div>
</div>
{{end}}
@@ -0,0 +1,32 @@
{{/* Delete an outbound domain (mockup out-domain-delete.html): what goes with it,
the one button that does it, and the way back. Moving the domain is the
alternative, beside it. */}}
{{define "content"}}
{{template "page_head" .Head}}
<div class="columns">
<div class="column is-7">
{{template "box_open" .Danger}}
{{template "body_open"}}
<p class="mb-3">{{template "inline" .Intro}}</p>
{{template "confirm_list" .Consequences}}
<p class="sp-muted mb-4">{{template "inline" .Note}}</p>
<form method="post" action="{{.Action}}">
<div class="buttons">
<button type="submit" class="button is-danger"><i class="ti ti-trash"></i>{{.Confirm}}</button>
<a class="button is-text" href="{{.KeepHref}}">Keep it</a>
</div>
</form>
{{template "body_close"}}
{{template "box_close"}}
</div>
<div class="column is-5">
{{template "box_open" .Moving}}
{{template "body_open"}}
<p>{{template "inline" .MovingText}}</p>
{{template "body_close"}}
{{template "box_close"}}
</div>
</div>
{{end}}
@@ -0,0 +1,84 @@
{{/* Domain settings (mockup out-domain-settings.html): the things set once and
rarely touched. Each box is its own form with its own POST and none of them
asks for the others' fields; a refusal comes back as the flash above them.
The delete entry is the global role's. */}}
{{define "content"}}
{{template "page_head" .Head}}
{{if .Flash}}{{template "flash" .Flash}}{{end}}
<div class="columns">
<div class="column is-6">
{{template "box_open" .Reports}}
{{template "body_open"}}
<form class="sp-form" method="post" action="{{.ReportsAction}}">
<div class="field"><label class="label" for="rua">Aggregate reports (rua=)</label>
<div class="control"><div class="select is-fullwidth"><select id="rua" name="dmarc_rua_mode" data-custom-mode="custom">{{$sel := .RuaSelected}}{{range .RuaOptions}}
<option value="{{.Value}}"{{if eq .Value $sel}} selected{{end}}>{{.Label}}</option>{{end}}
</select></div></div>
<p class="help">{{template "inline" .RuaHelp}}</p></div>
<div class="field" data-custom-address><label class="label" for="rua_email">Custom report address</label>
<div class="control"><input class="input" id="rua_email" name="dmarc_rua_email" type="email" value="{{.RuaCustom}}" placeholder="reports@your-mail-domain.com" autocomplete="off" autocapitalize="none" spellcheck="false"></div></div>
<button type="submit" class="button is-primary">Save report address</button>
</form>
{{template "body_close"}}
{{template "box_close"}}
{{template "box_open" .Export}}
{{template "body_open"}}
<p class="mb-3">One file with the DKIM key and every application's password hash — import it on another SelfPost and the published DNS needs no change. <strong>The file is a secret.</strong></p>
<form class="sp-form" method="post" action="{{.ExportAction}}">
<div class="field"><label class="checkbox"><input type="checkbox" name="encrypt" value="1"{{if .Encrypt}} checked{{end}} data-encrypt-toggle> Encrypt with a password (<code>.spde</code>)</label></div>
<div data-encrypt-fields>
<div class="sp-pair">
<div class="field"><label class="label" for="encpw">Password</label><div class="control"><input class="input" id="encpw" name="password" type="password" autocomplete="new-password" minlength="{{.PasswordMin}}" placeholder="at least {{.PasswordMin}} characters"></div></div>
<div class="field"><label class="label" for="encpw2">Repeat password</label><div class="control"><input class="input" id="encpw2" name="password_confirm" type="password" autocomplete="new-password"></div></div>
</div>
<p class="help mb-3">Keep this password: SelfPost does not store it, and the file cannot be opened without it.</p>
</div>
<button type="submit" class="button"><i class="ti ti-download"></i>Export domain</button>
</form>
{{template "body_close"}}
{{template "box_close"}}
</div>
<div class="column is-6">
{{template "box_open" .Limit}}
{{template "body_open"}}
<form class="sp-form mb-3" id="rate-limit" method="post" action="{{.LimitAction}}">
<div class="field"><label class="label" for="mode">Limit mode</label>
<div class="control"><div class="select is-fullwidth"><select id="mode" name="mode" data-ratelimit-mode>
<option value="manual"{{if not .Rate.Auto}} selected{{end}}>Manual</option>
<option value="auto"{{if .Rate.Auto}} selected{{end}}>Auto (from statistics)</option>
</select></div></div></div>
<div data-manual-fields>
<div class="sp-pair">
<div class="field"><label class="label" for="max">Messages (max {{.Rate.L1Messages}})</label><div class="control"><input class="input" id="max" name="max_messages" type="number" min="1" max="{{.Rate.L1Messages}}" value="{{.Rate.MaxMessages}}" placeholder="{{.Rate.L1Messages}}"></div></div>
<div class="field"><label class="label" for="win">Window, s</label><div class="control"><input class="input" id="win" name="window_seconds" type="number" min="1" value="{{.Rate.Window}}"></div></div>
</div>
<p class="help mb-3">Leave the message limit empty to use level 1 only.</p>
</div>
<div data-auto-fields>
<div class="field"><label class="label" for="mult">Multiplier ({{.Rate.MinMultiplier}}–{{.Rate.MaxMultiplier}}, default {{.Rate.DefaultMultiplier}})</label>
<div class="control"><input class="input" id="mult" name="auto_multiplier" type="number" min="{{.Rate.MinMultiplier}}" max="{{.Rate.MaxMultiplier}}" step="0.1" value="{{.Rate.Multiplier}}"></div>
<p class="help">{{template "inline" .Rate.AutoHelp}}</p></div>
</div>
</form>
<div class="buttons">
<button type="submit" class="button is-primary" form="rate-limit">Save limit</button>
{{if .Rate.Auto}}<form method="post" action="{{.RecalcAction}}"><button type="submit" class="button">Recalculate now</button></form>{{end}}
{{if .Rate.Active}}<form method="post" action="{{.LimitAction}}" 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="button is-danger is-light">Remove limit</button></form>{{end}}
</div>
<p class="sp-muted mt-4">{{template "inline" .LimitNote}}</p>
{{template "body_close"}}
{{template "box_close"}}
{{if .CanDelete}}
{{template "box_open" .Delete}}
{{template "body_open"}}
<p class="mb-3">{{template "inline" .DeleteText}}</p>
{{template "action" .DeleteLink}}
{{template "body_close"}}
{{template "box_close"}}
{{end}}
</div>
</div>
{{end}}
@@ -0,0 +1,35 @@
{{/* Outbound domains (mockup out-domains.html): the domains this relay signs and
sends for, and — for the global role, who alone adds and deletes domains —
the form that adds one. A domain administrator sees the list alone. */}}
{{define "content"}}
{{template "page_head" .Head}}
{{if .Flash}}{{template "flash" .Flash}}{{end}}
{{if .CanManage}}
{{template "box_open" .Add}}
{{template "body_open"}}
<form class="sp-form" method="post" action="/outbound/domains">
<div class="field has-addons">
<div class="control is-expanded has-icons-left"><input class="input" name="name" type="text" value="{{.AddName}}" placeholder="example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required><span class="icon is-left"><i class="ti ti-world"></i></span></div>
<div class="control"><button type="submit" class="button is-primary"><i class="ti ti-plus"></i>Add domain</button></div>
</div>
<p class="help">{{template "inline" .AddHelp}}</p>
</form>
{{template "body_close"}}
{{template "box_close"}}
{{end}}
{{template "box_open" .List}}
{{if .Rows}}
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Domain</th><th>DNS</th><th>Selector</th><th>Apps</th><th>{{.Days}} days</th><th></th></tr></thead>
<tbody>{{range .Rows}}
<tr><td><a href="{{.Href}}"><strong>{{.Name}}</strong></a></td><td>{{range $i, $t := .DNS}}{{if $i}} {{end}}{{template "tag" $t}}{{end}}</td><td class="sp-muted sp-mono">{{.Selector}}</td><td>{{.Apps}}</td><td class="sp-muted">{{.Activity}}</td><td class="sp-actions"><a href="{{.Href}}">Open</a>{{if .DeleteHref}}<a class="has-text-danger" href="{{.DeleteHref}}">Delete</a>{{end}}</td></tr>{{end}}
</tbody>
</table></div>
{{template "box_foot" .Legend}}
{{else}}
{{template "empty_state" .Empty}}
{{end}}
{{template "box_close"}}
{{end}}