00e36df553
Invert level-2 semantics so domain limits apply to every client IP and application limits with trusted IPs raise the ceiling above the domain (still capped by level 1). Panel shows L1, validates maxima, and documents the model on Settings. Co-authored-by: Cursor <cursoragent@cursor.com>
475 lines
18 KiB
HTML
475 lines
18 KiB
HTML
{{/* Wide so the .split pairs fill the column rather than the 48rem reading
|
|
measure (same pattern as Status). */}}
|
|
{{define "wide"}}wide{{end}}
|
|
|
|
{{define "content"}}
|
|
<h1>{{.Domain.Name}}</h1>
|
|
|
|
{{template "back_link" (back "/domains" "All domains")}}
|
|
|
|
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
|
{{if .RateLimitErr}}<div class="flash error">{{.RateLimitErr}}</div>{{end}}
|
|
|
|
{{if .NewCred}}
|
|
<div class="card credential" id="new-credential">
|
|
<h2>New application password</h2>
|
|
<p class="muted">Shown <strong>once only</strong> and not stored. Copy it now
|
|
— if it is lost, regenerate a new one.</p>
|
|
<label>Login</label>
|
|
<div class="code-row">
|
|
<span class="code">{{.NewCred.Login}}</span>
|
|
<button type="button" class="copy">Copy</button>
|
|
</div>
|
|
<label>Password</label>
|
|
<div class="code-row">
|
|
<span class="code">{{.NewCred.Password}}</span>
|
|
<button type="button" class="copy">Copy</button>
|
|
</div>
|
|
</div>
|
|
{{end}}
|
|
|
|
{{/* Two rows of two checks (.check-cols). */}}
|
|
<div class="card" id="dns-status">
|
|
<h2>DNS status <span class="st st-{{.DNS.Overall}}">{{.DNS.Overall}}</span></h2>
|
|
<p class="muted">Cached a few minutes — use <em>Re-check</em> after
|
|
publishing.</p>
|
|
|
|
<div class="check-cols">
|
|
<div class="check-col">
|
|
<label>DKIM <span class="st st-{{.DNS.DKIM.Status}}">{{.DNS.DKIM.Status}}</span></label>
|
|
<div class="field-pair host-type">
|
|
<div>
|
|
<label>Host / name</label>
|
|
<span class="code">{{.Record.Name}}</span>
|
|
</div>
|
|
<div class="field-type">
|
|
<label>Type</label>
|
|
<span class="code">TXT</span>
|
|
</div>
|
|
</div>
|
|
{{if .DNS.DKIM.Records}}
|
|
<label>Value</label>
|
|
<span class="code">{{range .DNS.DKIM.Records}}{{.}}
|
|
{{end}}</span>
|
|
{{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>
|
|
<div class="field-pair host-type">
|
|
<div>
|
|
<label>Host / name</label>
|
|
<span class="code">{{.Domain.Name}}</span>
|
|
</div>
|
|
<div class="field-type">
|
|
<label>Type</label>
|
|
<span class="code">TXT</span>
|
|
</div>
|
|
</div>
|
|
{{if .DNS.SPF.Records}}
|
|
<label>Value</label>
|
|
<span class="code">{{range .DNS.SPF.Records}}{{.}}
|
|
{{end}}</span>
|
|
{{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>
|
|
<div class="field-pair host-type">
|
|
<div>
|
|
<label>Host / name</label>
|
|
<span class="code">{{.DMARCName}}</span>
|
|
</div>
|
|
<div class="field-type">
|
|
<label>Type</label>
|
|
<span class="code">TXT</span>
|
|
</div>
|
|
</div>
|
|
{{if .DNS.DMARC.Records}}
|
|
<label>Value</label>
|
|
<span class="code">{{range .DNS.DMARC.Records}}{{.}}
|
|
{{end}}</span>
|
|
{{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>
|
|
<div class="field-pair host-type">
|
|
<div>
|
|
<label>Host / name</label>
|
|
<span class="code">{{.ReportAuthName}}</span>
|
|
</div>
|
|
<div class="field-type">
|
|
<label>Type</label>
|
|
<span class="code">TXT</span>
|
|
</div>
|
|
</div>
|
|
{{if .DNS.DMARCReportAuth.Records}}
|
|
<label>Value</label>
|
|
<span class="code">{{range .DNS.DMARCReportAuth.Records}}{{.}}
|
|
{{end}}</span>
|
|
{{end}}
|
|
<p class="{{if eq .DNS.DMARCReportAuth.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DMARCReportAuth.Detail}}</p>
|
|
{{else}}
|
|
<label>Report authorization</label>
|
|
<p class="muted">Not required (no external <code>rua=</code>).</p>
|
|
{{end}}
|
|
</div>
|
|
</div>
|
|
|
|
<form class="inline" method="post" action="/domains/{{.Domain.ID}}/dns-recheck">
|
|
<button type="submit">Re-check</button>
|
|
</form>
|
|
</div>
|
|
|
|
<div class="split">
|
|
<div class="card" id="dkim-spf">
|
|
<h2>DKIM and SPF records</h2>
|
|
|
|
<p class="check-col-title">DKIM</p>
|
|
<div class="field-pair host-type">
|
|
<div>
|
|
<label>Host / name</label>
|
|
<div class="code-row">
|
|
<span class="code">{{.Record.Name}}</span>
|
|
<button type="button" class="copy">Copy</button>
|
|
</div>
|
|
</div>
|
|
<div class="field-type">
|
|
<label>Type</label>
|
|
<span class="code">TXT</span>
|
|
</div>
|
|
</div>
|
|
|
|
<label>Value</label>
|
|
<div class="code-row">
|
|
<span class="code">{{.Record.Value}}</span>
|
|
<button type="button" class="copy">Copy</button>
|
|
</div>
|
|
|
|
<p class="muted">Not a secret. Signed with selector
|
|
<strong>{{.Domain.DKIMSelector}}</strong>.</p>
|
|
|
|
<p class="check-col-title">SPF</p>
|
|
<div class="field-pair host-type">
|
|
<div>
|
|
<label>Host / name</label>
|
|
<div class="code-row">
|
|
<span class="code">{{.Domain.Name}}</span>
|
|
<button type="button" class="copy">Copy</button>
|
|
</div>
|
|
</div>
|
|
<div class="field-type">
|
|
<label>Type</label>
|
|
<span class="code">TXT</span>
|
|
</div>
|
|
</div>
|
|
|
|
<label>Value</label>
|
|
<div class="code-row">
|
|
<span class="code">{{.SPFExample}}</span>
|
|
<button type="button" class="copy">Copy</button>
|
|
</div>
|
|
|
|
<p class="muted">Merge into an existing SPF if the domain already has one —
|
|
do not publish a second record.</p>
|
|
</div>
|
|
|
|
<div class="card" id="dmarc">
|
|
<h2>DMARC record</h2>
|
|
|
|
<div class="field-pair host-type">
|
|
<div>
|
|
<label>Host / name</label>
|
|
<div class="code-row">
|
|
<span class="code">{{.DMARCName}}</span>
|
|
<button type="button" class="copy">Copy</button>
|
|
</div>
|
|
</div>
|
|
<div class="field-type">
|
|
<label>Type</label>
|
|
<span class="code">TXT</span>
|
|
</div>
|
|
</div>
|
|
|
|
<label>Value{{if eq .DMARCSource "settings"}} <span class="muted">(from Settings)</span>{{else if eq .DMARCSource "custom"}} <span class="muted">(custom)</span>{{else if eq .DMARCSource "none"}} <span class="muted">(no reports)</span>{{end}}</label>
|
|
<div class="code-row">
|
|
<span class="code">{{.DMARCExample}}</span>
|
|
<button type="button" class="copy">Copy</button>
|
|
</div>
|
|
|
|
{{if .SameDomainRUA}}
|
|
<p class="error">The report address is on this sending domain. SelfPost does
|
|
not receive inbound mail — use a mailbox elsewhere or wait for in-panel report
|
|
reception in a future release.</p>
|
|
{{end}}
|
|
|
|
{{if .NeedsReportAuth}}
|
|
<p class="check-col-title">Report authorization</p>
|
|
<div class="field-pair host-type">
|
|
<div>
|
|
<label>Host / name</label>
|
|
<div class="code-row">
|
|
<span class="code">{{.ReportAuthName}}</span>
|
|
<button type="button" class="copy">Copy</button>
|
|
</div>
|
|
</div>
|
|
<div class="field-type">
|
|
<label>Type</label>
|
|
<span class="code">TXT</span>
|
|
</div>
|
|
</div>
|
|
|
|
<label>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> does not affect delivery. Tighten to
|
|
<code>p=quarantine</code> then <code>p=reject</code> once reports look clean.
|
|
Report address is set under <a href="#domain-settings">Domain settings</a>.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="split">
|
|
<div class="card" id="connection">
|
|
<h2>Connection settings</h2>
|
|
<p class="muted">Same for every domain. Authenticate with an application
|
|
login from below.</p>
|
|
|
|
<label>Server</label>
|
|
<div class="code-row">
|
|
<span class="code">{{.Hostname}}</span>
|
|
<button type="button" class="copy">Copy</button>
|
|
</div>
|
|
|
|
<label>Port and encryption</label>
|
|
<span class="code">465 — SSL/TLS (implicit){{if .SubmissionEnabled}}
|
|
587 — STARTTLS (submission){{end}}</span>
|
|
|
|
<p class="muted">Auth required on every port. The password is shown once at
|
|
create or regenerate.</p>
|
|
</div>
|
|
|
|
{{/* Create form beside connection settings, mirroring "Add a sending domain"
|
|
above the domains list. */}}
|
|
<div class="card" id="add-application">
|
|
<h2>Add an application</h2>
|
|
<form method="post" action="/domains/{{.Domain.ID}}/applications">
|
|
<label for="login">Login</label>
|
|
<input id="login" name="login" type="text" placeholder="prod-server"
|
|
autocomplete="off" autocapitalize="none" spellcheck="false"
|
|
value="{{.FormLogin}}" required>
|
|
|
|
<label for="mode">Address mode</label>
|
|
<select id="mode" name="mode" data-list-mode="{{.List}}">
|
|
<option value="{{.Wildcard}}" {{if eq .FormMode .Wildcard}}selected{{end}}>Any address of the domain</option>
|
|
<option value="{{.List}}" {{if eq .FormMode .List}}selected{{end}}>Specific addresses (list)</option>
|
|
</select>
|
|
|
|
<div data-addresses>
|
|
<label for="addresses">Addresses (one per line or comma-separated)</label>
|
|
<textarea id="addresses" name="addresses" rows="3"
|
|
placeholder="alerts@{{.Domain.Name}}">{{.FormAddrs}}</textarea>
|
|
</div>
|
|
|
|
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
|
<button type="submit">Create application</button>
|
|
</form>
|
|
<p class="muted">Password shown once. Login unique across domains; letters,
|
|
digits, '.', '-' and '_'.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card" id="applications">
|
|
<h2>Applications</h2>
|
|
<p class="muted">SASL logins for this domain — wildcard (*@domain) or a fixed
|
|
address list.</p>
|
|
|
|
{{if .Apps}}
|
|
<ul class="apps">
|
|
{{range .Apps}}
|
|
<li class="app">
|
|
<p class="app-login">{{.Login}}</p>
|
|
<p class="app-addr muted">
|
|
{{if eq .AddressMode $.Wildcard}}Any address of the domain — *@{{$.Domain.Name}}
|
|
{{else}}Fixed list — {{range $i, $a := .Addresses}}{{if $i}}, {{end}}{{$a}}{{end}}{{end}}
|
|
</p>
|
|
<!-- One Edit panel (mode ‖ rate limit) opened by a checkbox and label
|
|
rather than <details>, so the button row stays intact — see
|
|
.panel-toggle in panel.css. -->
|
|
<div class="actions">
|
|
<input class="panel-toggle t-edit" id="edit-{{.ID}}" type="checkbox">
|
|
<label class="toggle for-edit" for="edit-{{.ID}}">Edit{{if .HasLimit}} (limit 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-edit">
|
|
<div class="check-cols">
|
|
<div class="check-col">
|
|
<p class="check-col-title">Address mode</p>
|
|
<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="check-col">
|
|
<p class="check-col-title">Optional trusted-IP override</p>
|
|
<p class="muted">Listed client IPs get a higher ceiling than the
|
|
domain limit (still capped by level 1:
|
|
{{$.L1Messages}} / {{$.L1Window}}s —
|
|
<a href="/settings">Settings</a>).
|
|
{{if $.DomainHasRL}}Domain ceiling: {{$.DomainRLMaxNum}}.{{else}}No domain ceiling (level 1 only for other IPs).{{end}}</p>
|
|
<form id="rl-{{.ID}}" method="post" action="/applications/{{.ID}}/ratelimit">
|
|
<label>Trusted client IPs (required; one per line or comma-separated)</label>
|
|
<textarea name="allowed_ips" rows="2" placeholder="203.0.113.10">{{.IPsText}}</textarea>
|
|
<p class="muted">Only these IPs use the application ceiling and
|
|
skip the domain limit. Other IPs stay under the domain (or
|
|
level 1).</p>
|
|
<div class="field-pair">
|
|
<div>
|
|
<label>Message limit</label>
|
|
<input name="max_messages" type="number" min="1" max="{{$.L1Messages}}"
|
|
value="{{.MaxText}}" placeholder="{{$.L1Messages}}">
|
|
</div>
|
|
<div>
|
|
<label>Window (seconds)</label>
|
|
<input name="window_seconds" type="number" min="1" value="{{.WindowVal}}">
|
|
</div>
|
|
</div>
|
|
</form>
|
|
<!-- Saving and removing the limit are two posts; the Save
|
|
button is bound by form= id so both buttons share a row. -->
|
|
<div class="panel-buttons">
|
|
<button type="submit" form="rl-{{.ID}}">Save limit</button>
|
|
{{if .HasLimit}}
|
|
<form class="inline" method="post" action="/applications/{{.ID}}/ratelimit"
|
|
data-confirm="Remove the rate limit for {{.Login}}? The domain limit (or level 1) will apply.">
|
|
<input type="hidden" name="clear" value="1">
|
|
<button type="submit" class="danger">Remove limit</button>
|
|
</form>
|
|
{{end}}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
{{end}}
|
|
</ul>
|
|
{{else}}
|
|
<p class="muted">No applications yet. Add one above to get started.</p>
|
|
{{end}}
|
|
</div>
|
|
|
|
<div class="card" id="domain-settings">
|
|
<h2>Domain settings</h2>
|
|
|
|
<div class="check-cols">
|
|
<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>
|
|
<form method="post" action="/domains/{{.Domain.ID}}/dmarc">
|
|
<label for="dmarc_rua_mode">Aggregate reports (rua=)</label>
|
|
<select id="dmarc_rua_mode" name="dmarc_rua_mode" data-custom-mode="custom">
|
|
<option value="inherit"{{if eq .DMARCRuaMode "inherit"}} selected{{end}}>Same as Settings{{if .ProfileDMARCEmail}} ({{.ProfileDMARCEmail}}){{end}}</option>
|
|
<option value="none"{{if eq .DMARCRuaMode "none"}} selected{{end}}>No aggregate reports</option>
|
|
<option value="custom"{{if eq .DMARCRuaMode "custom"}} selected{{end}}>Custom address</option>
|
|
</select>
|
|
|
|
<div data-custom-address>
|
|
<label for="dmarc_rua_email">Custom report address</label>
|
|
<input id="dmarc_rua_email" name="dmarc_rua_email" type="email"
|
|
autocapitalize="none" spellcheck="false" value="{{.DMARCRuaCustom}}"
|
|
placeholder="reports@your-mail-domain.com">
|
|
</div>
|
|
|
|
<button type="submit">Save DMARC report settings</button>
|
|
</form>
|
|
</div>
|
|
|
|
<div class="check-col">
|
|
<p class="check-col-title">Optional level-2 sending rate limit</p>
|
|
<p class="muted">Applies to every client IP on this domain. Status:
|
|
{{if .DomainHasRL}}<strong>active</strong>{{else}}inactive (level 1 only){{end}}.
|
|
Level 1 backstop: {{.L1Messages}} messages / {{.L1Window}}s
|
|
(<a href="/settings">Settings</a>). Leave the message limit empty to
|
|
use level 1 only.</p>
|
|
|
|
<form method="post" action="/domains/{{.Domain.ID}}/ratelimit">
|
|
<div class="field-pair">
|
|
<div>
|
|
<label for="d_max">Message limit</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>
|
|
|
|
<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>
|
|
</div>
|
|
|
|
<div class="split">
|
|
<div class="card" id="export">
|
|
<h2>Export domain</h2>
|
|
<p class="muted"><strong>Secret file</strong> — transfer securely, or encrypt
|
|
below as <code>.spde</code>.</p>
|
|
{{if .ExportErr}}<p class="error">{{.ExportErr}}</p>{{end}}
|
|
<form method="post" action="/domains/{{.Domain.ID}}/export">
|
|
{{template "encryptfields" .}}
|
|
<button type="submit">Export domain</button>
|
|
</form>
|
|
</div>
|
|
|
|
<div class="card" id="danger">
|
|
<h2>Danger zone</h2>
|
|
<p class="muted">Deletes the DKIM key and every application on this domain.</p>
|
|
<a class="danger" href="/domains/{{.Domain.ID}}/delete">Delete domain</a>
|
|
</div>
|
|
</div>
|
|
{{end}}
|