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:
mix
2026-10-10 01:39:20 +03:00
co-authored by Claude Opus 5.5
parent 2f1ec65b30
commit 610023ef6a
29 changed files with 1152 additions and 291 deletions
@@ -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}}
-54
View File
@@ -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}}