panel-redesign: Inbound domains, domain and delete on the kit; Spam filter box
Stage 2, step 5. Written by Sonnet, reviewed by Opus.
- Three pages named after their mockups: in-domains, in-domain,
in-domain-delete, with typed page data (view/inbound.go), fixtures
and tests. The engine pages inbound, inbound_domain and
inbound_delete are gone with their templates and are off
legacy_pages.txt.
- The Spam filter box (plan, Who sees what). The panel now reads
INBOUND_ANTISPAM_MILTER and INBOUND_ANTISPAM_MILTER_ACTION, with the
semantics of build/postfix-config.sh (on only with the inbound relay
on and a milter set; accept | tempfail). It reports them, nothing
more:
on Applies to / Filter (the address, global role only) / If the
filter is down (accepted unfiltered | deferred)
off one sentence; the variable is named for the global role only
The list shows "Spam filter on|off" in its head.
- Handlers pass typed data; routes, POST targets, field names,
validation and authorization (global: all inbound domains; domain
role: the assigned ones, another tenant's id is a 404; add and
delete are global-only) are unchanged. A test saves the upstream and
recipients forms untouched and finds the domain unchanged.
Left out of the mockups' Spam filter box and menu - no source before
inbound-antispam-panel (2.1): the engine name, the quarantine row, the
"refused at the door, see the log" row, the lists row, "Log for this
domain". Also "Checked N min ago" on the MX box: the check carries no
time.
Not done: the filter's socket on Health and Overview. internal/health
checks a unix socket by stat; the documented form of the filter's
address is inet:host:port and needs a dial that does not exist yet.
Differences from the mockups, for the owner:
- refusals and confirmations are the kit flash above the boxes;
- the MX tag is coloured by the check in every state; an MX that
points elsewhere shows "In DNS now";
- the lead of the domain page follows what is saved ("Not accepting
mail yet: no upstream is saved");
- the delete page lists only what the domain has;
- /help#inbound opens Help at the top until the Help step.
Evidence
- docs/assets/panel-redesign/evidence/in-domains.png, in-domain.png,
in-domain-delete.png: 1440 px wide, headless Edge, real templates
served locally with their fixtures (filter on) - NOT from the
container (no Docker on this machine).
- panel/audit.js on the three pages at 1440 and 1280: 352 texts
checked, problems: [].
- That the panel process sees the two variables inside the container
is read from supervisord.conf and entrypoint.sh, not run.
- go test -count=1 ./internal/web/... ./cmd/panel
ok internal/web
ok internal/web/auth
ok internal/web/handlers
ok internal/web/validate
ok internal/web/view
ok cmd/panel
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
{{/* One inbound domain (mockup in-domain.html): the MX record to publish and what
|
||||
DNS says about it, where accepted mail is handed on, who is accepted, and
|
||||
what the spam filter does for the domain. The upstream and the recipients
|
||||
are each a form with their own POST. The side menu leads to the boxes and,
|
||||
for the global role, to deleting the domain. */}}
|
||||
{{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}}
|
||||
{{template "dns_record" .Record}}
|
||||
{{template "box_close"}}
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-6">
|
||||
{{template "box_open" .Upstream}}
|
||||
{{template "body_open"}}
|
||||
<form class="sp-form" method="post" action="{{.UpstreamAction}}">
|
||||
<div class="sp-row">
|
||||
<div class="field"><label class="label" for="host">Host</label><div class="control"><input class="input sp-mono" id="host" name="host" value="{{.Host}}" autocomplete="off" autocapitalize="none" spellcheck="false" required></div></div>
|
||||
<div class="field"><label class="label" for="port">Port</label><div class="control"><input class="input sp-mono" id="port" name="port" inputmode="numeric" value="{{.Port}}" required></div></div>
|
||||
</div>
|
||||
<div class="field"><label class="label" for="tls">TLS to upstream</label>
|
||||
<div class="control"><div class="select is-fullwidth"><select id="tls" name="tls_mode">{{$sel := .TLSSelected}}{{range .TLSOptions}}
|
||||
<option value="{{.Value}}"{{if eq .Value $sel}} selected{{end}}>{{.Label}}</option>{{end}}
|
||||
</select></div></div></div>
|
||||
<button type="submit" class="button is-primary">Save upstream</button>
|
||||
</form>
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
</div>
|
||||
<div class="column is-6">
|
||||
{{template "box_open" .Recipients}}
|
||||
{{template "body_open"}}
|
||||
<form class="sp-form" method="post" action="{{.RecipientsAction}}">
|
||||
<div class="field"><label class="label" for="rmode">Who to accept</label>
|
||||
<div class="control"><div class="select is-fullwidth"><select id="rmode" name="recipient_mode" data-list-mode="list">{{$mode := .ModeSelected}}{{range .ModeOptions}}
|
||||
<option value="{{.Value}}"{{if eq .Value $mode}} selected{{end}}>{{.Label}}</option>{{end}}
|
||||
</select></div></div></div>
|
||||
<div class="field" data-addresses><label class="label" for="addresses">Addresses</label>
|
||||
<div class="control"><textarea class="textarea sp-mono" id="addresses" name="addresses" rows="4">{{.Addresses}}</textarea></div>
|
||||
<p class="help">{{template "inline" .RecipientsHelp}}</p></div>
|
||||
<button type="submit" class="button is-primary">Save recipients</button>
|
||||
</form>
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{template "box_open" .Filter}}
|
||||
{{template "body_open"}}
|
||||
{{if .FilterFacts}}{{template "facts" .FilterFacts}}{{else}}<p>{{template "inline" .FilterText}}</p>{{end}}
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,23 @@
|
||||
{{/* Delete an inbound domain (mockup in-domain-delete.html): what goes with it,
|
||||
the one button that does it, and the way back. */}}
|
||||
{{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>
|
||||
{{end}}
|
||||
@@ -0,0 +1,35 @@
|
||||
{{/* Inbound domains (mockup in-domains.html): the domains port 25 accepts mail
|
||||
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="/inbound/domains">
|
||||
<div class="field has-addons">
|
||||
<div class="control is-expanded has-icons-left"><input class="input" name="name" type="text" value="{{.AddName}}" placeholder="lists.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>Upstream</th><th>Recipients</th><th>TLS to upstream</th><th></th></tr></thead>
|
||||
<tbody>{{range .Rows}}
|
||||
<tr><td><a href="{{.Href}}"><strong>{{.Name}}</strong></a></td><td>{{template "tag" .DNS}}</td><td class="sp-mono sp-muted">{{.Upstream}}</td><td>{{.Recipients}}</td><td>{{template "tag" .TLS}}</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}}
|
||||
@@ -1,54 +0,0 @@
|
||||
{{define "wide"}}wide{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<h1>Inbound</h1>
|
||||
|
||||
<p class="muted">Backup-MX / forwarder. Accepts on port 25 only for listed
|
||||
domains. Recipients are either an allow-list or any address at that domain.
|
||||
Off by default in Compose.</p>
|
||||
|
||||
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||
|
||||
<div class="card">
|
||||
<h2>Forwarding</h2>
|
||||
{{/* Adding and deleting a domain stay with the global role; a domain
|
||||
administrator works with the inbound domains assigned to them. */}}
|
||||
{{if .IsGlobal}}
|
||||
<form method="post" action="/inbound/domains">
|
||||
<label for="name">Add inbound domain</label>
|
||||
<div class="input-row">
|
||||
<input id="name" name="name" type="text" placeholder="lists.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>
|
||||
{{end}}
|
||||
{{if .Domains}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Domain</th><th>DNS</th><th>Upstream</th><th>Recipients</th><th>TLS</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Domains}}
|
||||
<tr>
|
||||
<td><a href="/inbound/domains/{{.ID}}">{{.Name}}</a></td>
|
||||
<td><a class="st st-{{.DNS}}" href="/inbound/domains/{{.ID}}">{{.DNS}}</a></td>
|
||||
<td class="muted">{{.Upstream}}</td>
|
||||
<td>{{.RcptLabel}}</td>
|
||||
<td><span class="st st-{{if eq .TLSMode "encrypt"}}ok{{else}}unknown{{end}}">{{.TLSLabel}}</span></td>
|
||||
<td class="actions">{{if $.IsGlobal}}<a class="danger" href="/inbound/domains/{{.ID}}/delete">Delete</a>{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="muted">The <em>DNS</em> badge is the MX check: at least one MX must
|
||||
point at this server. Results are cached for a few minutes; open a domain for
|
||||
the lookup and a <em>Re-check</em> button.</p>
|
||||
{{else}}
|
||||
<p class="muted">No inbound domains yet. Add one above, then set the upstream
|
||||
on its page. Mail is not accepted until an upstream host is saved.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -1,20 +0,0 @@
|
||||
{{define "content"}}
|
||||
<h1>Delete {{.Domain.Name}}</h1>
|
||||
|
||||
{{template "back_link" (back (printf "/inbound/domains/%d" .Domain.ID) (printf "Back to %s" .Domain.Name))}}
|
||||
|
||||
<div class="card">
|
||||
<h2>Confirm deletion</h2>
|
||||
<p>You are about to stop accepting inbound mail for <strong>{{.Domain.Name}}</strong>. This will:</p>
|
||||
<ul>
|
||||
<li>remove it from <code>relay_domains</code> and the recipient map;</li>
|
||||
<li>stop forwarding{{if ne .Upstream "—"}} to <strong>{{.Upstream}}</strong>{{end}};</li>
|
||||
<li>leave outbound sending domains untouched.</li>
|
||||
</ul>
|
||||
<p class="muted">This cannot be undone from a backup of inbound maps alone
|
||||
unless you restore one. Remove the MX if you do not plan to re-add the domain.</p>
|
||||
<form method="post" action="/inbound/domains/{{.Domain.ID}}/delete">
|
||||
<button type="submit" class="danger">Delete {{.Domain.Name}}</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -1,118 +0,0 @@
|
||||
{{define "wide"}}wide{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<h1>{{.Domain.Name}}</h1>
|
||||
|
||||
{{template "back_link" (back "/inbound/domains" "All inbound domains")}}
|
||||
|
||||
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||
|
||||
<div class="card" id="dns-status">
|
||||
<h2>DNS status <span class="st st-{{.MX.Status}}">{{.MX.Status}}</span></h2>
|
||||
<p class="muted">Cached a few minutes — use <em>Re-check</em> after publishing.
|
||||
Unlike outbound, inbound needs an MX pointing at this server.</p>
|
||||
<label>MX <span class="st st-{{.MX.Status}}">{{.MX.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">MX</span>
|
||||
</div>
|
||||
</div>
|
||||
<label>Value</label>
|
||||
{{if .MX.Records}}
|
||||
<span class="code">{{range .MX.Records}}{{.}}
|
||||
{{end}}</span>
|
||||
{{else}}
|
||||
<span class="code muted">No MX records found.</span>
|
||||
{{end}}
|
||||
{{if eq .MX.Status "ok"}}
|
||||
<p class="muted">{{.MX.Detail}}</p>
|
||||
{{else}}
|
||||
<p class="error">{{.MX.Detail}}</p>
|
||||
{{end}}
|
||||
<form method="post" action="/inbound/domains/{{.Domain.ID}}/dns-recheck">
|
||||
<button type="submit">Re-check</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="split">
|
||||
<div class="card">
|
||||
<h2>Upstream</h2>
|
||||
<p class="muted">Where accepted mail is handed off. Not a mailbox.</p>
|
||||
<form method="post" action="/inbound/domains/{{.Domain.ID}}/upstream">
|
||||
<label for="host">Host</label>
|
||||
<input id="host" name="host" type="text" value="{{.Domain.Host}}"
|
||||
autocomplete="off" autocapitalize="none" spellcheck="false" required>
|
||||
<label for="port">Port</label>
|
||||
<input id="port" name="port" type="text" inputmode="numeric" value="{{.Domain.Port}}" required>
|
||||
<label for="tls_mode">TLS to upstream</label>
|
||||
<select id="tls_mode" name="tls_mode">
|
||||
<option value="may" {{if eq .Domain.TLSMode "may"}}selected{{end}}>Opportunistic</option>
|
||||
<option value="encrypt" {{if eq .Domain.TLSMode "encrypt"}}selected{{end}}>Required</option>
|
||||
<option value="none" {{if eq .Domain.TLSMode "none"}}selected{{end}}>Off</option>
|
||||
</select>
|
||||
{{if .TransportErr}}<p class="error">{{.TransportErr}}</p>{{end}}
|
||||
<button type="submit">Save upstream</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>MX record to publish</h2>
|
||||
<p class="muted">Add this MX so the internet delivers here. Keep any existing
|
||||
primary MX if this is backup-MX.</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">MX</span>
|
||||
</div>
|
||||
</div>
|
||||
<label>Value</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.MXValue}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="split">
|
||||
<div class="card">
|
||||
<h2>Valid recipients</h2>
|
||||
<p class="muted">Who this domain accepts on port 25. Same idea as an
|
||||
application's address mode: a list, or any address at the domain.</p>
|
||||
<form method="post" action="/inbound/domains/{{.Domain.ID}}/recipients">
|
||||
<label for="recipient_mode">Who to accept</label>
|
||||
<select id="recipient_mode" name="recipient_mode" data-list-mode="list">
|
||||
<option value="list" {{if eq .Domain.RecipientMode "list"}}selected{{end}}>Listed addresses only</option>
|
||||
<option value="any" {{if eq .Domain.RecipientMode "any"}}selected{{end}}>Any recipient at this domain</option>
|
||||
</select>
|
||||
<div data-addresses>
|
||||
<label for="addresses">Addresses (one per line or comma-separated)</label>
|
||||
<textarea id="addresses" name="addresses" rows="6">{{.RecipientText}}</textarea>
|
||||
<p class="muted">Unknown recipients are rejected at RCPT so this relay
|
||||
does not generate backscatter.</p>
|
||||
</div>
|
||||
<p class="muted">Every address at this domain is accepted and forwarded when
|
||||
“any recipient” is selected. Prefer a list unless the upstream rejects
|
||||
unknowns — otherwise this relay may generate backscatter.</p>
|
||||
{{if .RecipientErr}}<p class="error">{{.RecipientErr}}</p>{{end}}
|
||||
<button type="submit">Save recipients</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Danger zone</h2>
|
||||
<p class="muted">Stops accepting mail for this domain. Does not touch
|
||||
outbound sending domains.</p>
|
||||
{{if .IsGlobal}}<p><a class="danger" href="/inbound/domains/{{.Domain.ID}}/delete">Delete inbound domain</a></p>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user