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
+35
View File
@@ -35,6 +35,10 @@ var pageFixtures = map[string]func() any{
"out-app-created": func() any { return outAppCreatedFixture() },
"out-domain-delete": func() any { return outDomainDeleteFixture() },
"in-domains": func() any { return inDomainsFixture() },
"in-domain": func() any { return inDomainFixture() },
"in-domain-delete": func() any { return inDomainDeleteFixture() },
"out-log": func() any { return outLogFixture() },
"out-message": func() any { return outMessageFixture() },
"dmarc": func() any { return dmarcHubFixture() },
@@ -307,3 +311,34 @@ func dmarcReportFixture() *DMARCReport {
{Source: "198.51.100.44", Count: 7, Disposition: "none", SPF: "fail", DKIM: "fail", HeaderFrom: "shop.example.org"},
})
}
// inDomainsFixture is the mockup's Inbound domains: two domains, one that lists
// its four recipients and needs TLS to its upstream, one that accepts any
// address and takes whatever its upstream offers.
func inDomainsFixture() *InDomains {
row := func(in InDomainInput) InDomainRow { return NewInDomainRow(in, true) }
return NewInDomains(admin(), true).WithFilter(true).WithRows([]InDomainRow{
row(InDomainInput{ID: 1, Name: "lists.example.org", DNSStatus: "ok", Host: "mx.internal", Port: 25,
TLSMode: "encrypt", RecipientMode: "list", RecipientCount: 4}),
row(InDomainInput{ID: 2, Name: "acme.io", DNSStatus: "ok", Host: "10.0.4.12", Port: 2525,
TLSMode: "may", RecipientMode: "any"}),
})
}
// inDomainDeleteFixture is the mockup's confirmation: a domain with an upstream
// and four listed recipients.
func inDomainDeleteFixture() *InDomainDelete {
return NewInDomainDelete(admin(), 1, "lists.example.org", true, 4)
}
// inDomainFixture is the mockup's domain page: the MX published and pointing
// here, an upstream that needs TLS, four listed recipients and a spam filter
// that is on.
func inDomainFixture() *InDomain {
p := NewInDomain(admin(), InDomainInput{ID: 1, Name: "lists.example.org", DNSStatus: "ok", Host: "mx.internal", Port: 25,
TLSMode: "encrypt", RecipientMode: "list", RecipientCount: 4,
Addresses: []string{"announce@lists.example.org", "dev@lists.example.org", "owner@lists.example.org", "security@lists.example.org"}})
p.WithMX("mail.example.org", DNSCheck{Status: "ok", Found: []string{"10 mail.example.org."}})
p.WithFilter(true, "inet:antispam:11332", "accept")
return p
}
+430
View File
@@ -0,0 +1,430 @@
package view
// The data of the Inbound domain pages: the list (templates/in_domains.html,
// mockup in-domains.html), one domain (in_domain.html, in-domain.html) and the
// confirmation before a domain is deleted (in_domain_delete.html,
// in-domain-delete.html).
//
// A page is a typed struct that embeds Meta, built by a constructor and filled
// by With… methods; the handler reads the stores and the DNS checker, the view
// decides what is said and how it is worded.
import (
"strconv"
"strings"
)
const inboundDomains = "/inbound/domains"
// InboundDomainHref is the address of an inbound domain's page.
func InboundDomainHref(id int64) string { return inboundDomains + "/" + strconv.FormatInt(id, 10) }
// The values of an inbound domain's recipient mode and TLS mode, as the store
// keeps them.
const (
recipientModeAny = "any"
tlsModeEncrypt = "encrypt"
tlsModeNone = "none"
)
// ---- Inbound domains
// InDomains is the data of the list. The global role adds and deletes domains
// (CanManage); a domain administrator sees the list alone, so the add box is
// absent for them and the table is the first box.
type InDomains struct {
Meta
Head Head
Flash *Flash
CanManage bool
Add Box
AddHelp Text
// AddName is what was typed into the form, shown again when it is refused.
AddName string
List Box
Rows []InDomainRow
Empty EmptyState
Legend Foot
filter *bool
}
// InDomainRow is one line of the Domains table: the verdict of the MX check as
// a tag, where accepted mail is handed on, who is accepted and the TLS the hand
// over needs. DeleteHref is empty when the viewer may not delete.
type InDomainRow struct {
Name string
Href string
DeleteHref string
DNS Tag
Upstream string
Recipients string
TLS Tag
}
// InDomainInput is what the list knows of one inbound domain: the store's
// fields and the status the MX check returned (ok, warn, error or unknown).
// Host is empty while no upstream is saved.
type InDomainInput struct {
ID int64
Name string
DNSStatus string
Host string
Port int
TLSMode string
RecipientMode string
RecipientCount int
// Addresses is the listed recipients, which only the page of one domain
// shows.
Addresses []string
}
// NewInDomainRow words one domain of the list. canDelete is the global role.
func NewInDomainRow(in InDomainInput, canDelete bool) InDomainRow {
href := InboundDomainHref(in.ID)
r := InDomainRow{
Name: in.Name, Href: href,
DNS: Tag{Status: in.DNSStatus, Label: "MX"},
Upstream: upstreamLabel(in.Host, in.Port),
Recipients: recipientsLabel(in.RecipientMode, in.RecipientCount),
TLS: tlsTag(in.TLSMode),
}
if canDelete {
r.DeleteHref = href + "/delete"
}
return r
}
// upstreamLabel is host:port, or a dash while no upstream is saved.
func upstreamLabel(host string, port int) string {
if host == "" {
return "—"
}
return host + ":" + strconv.Itoa(port)
}
// recipientsLabel says who a domain accepts: "any address" or the number of
// listed ones.
func recipientsLabel(mode string, n int) string {
if mode == recipientModeAny {
return "any address"
}
return plural(int64(n), "address", "addresses")
}
// tlsTag is the TLS a domain's upstream needs: green when it is required,
// neutral for the two modes that let mail through in the clear.
func tlsTag(mode string) Tag {
switch mode {
case tlsModeEncrypt:
return Tag{Status: "ok", Label: "required"}
case tlsModeNone:
return Tag{Status: "off", Label: "off"}
}
return Tag{Status: "off", Label: "opportunistic"}
}
// NewInDomains builds the list for a viewer. canManage is the global role.
func NewInDomains(m Meta, canManage bool) *InDomains {
m.Title, m.Section, m.Page = "Inbound domains", "inbound", "domains"
p := &InDomains{
Meta: m,
Head: Head{
Kicker: "Inbound",
Title: "Inbound domains",
Lead: Plain("Backup-MX / forwarder. Port 25 accepts mail only for the domains listed here and hands it to their upstream. No mailboxes."),
},
CanManage: canManage,
List: Box{No: "01", Title: "Domains"},
Empty: EmptyState{Icon: "ti-world-download", Text: Plain("No inbound domains yet.")},
Legend: Foot{Bare: true, Text: Plain("The MX tag is green when at least one MX of the domain points at this server.")},
}
if canManage {
p.Add = Box{No: "01", Title: "Add a domain"}
p.List.No = "02"
p.AddHelp = Plain("Mail is not accepted until an upstream host is saved on the domain's page. Adding and deleting domains is for a global administrator; a domain administrator sees the list without this form and without Delete.")
p.Empty.Text = Plain("No inbound domains yet. Add the first one above, then set its upstream on its page.")
}
return p
}
// WithRows sets the table and counts it in the box head.
func (p *InDomains) WithRows(rows []InDomainRow) *InDomains {
p.Rows = rows
p.setEnd()
return p
}
// WithFilter says in the box head whether the instance filters inbound mail.
func (p *InDomains) WithFilter(on bool) *InDomains {
p.filter = &on
p.setEnd()
return p
}
// setEnd writes the box head's end slot: the spam filter's state, when it is
// known, and how many domains the table lists.
func (p *InDomains) setEnd() {
count := plural(int64(len(p.Rows)), "domain", "domains")
if p.filter == nil {
p.List.End = Plain(count)
return
}
p.List.End = Rich("Spam filter ", filterTag(*p.filter), " · "+count)
}
// filterTag is the on / off tag of the spam filter.
func filterTag(on bool) Inline {
if on {
return TagOf("ok", "on")
}
return TagOf("off", "off")
}
// WithResult shows the result of the last action, or the refusal of the add
// form (with what was typed kept in the field), between the head and the first
// box.
func (p *InDomains) WithResult(flash, formErr, typed string) *InDomains {
p.AddName = typed
p.Flash = resultFlash(flash, formErr)
return p
}
// ---- Delete an inbound domain
// InDomainDelete is the data of the confirmation before an inbound domain is
// deleted: what goes with it, the one button that does it, and the way out.
type InDomainDelete struct {
Meta
Head Head
Danger Box
Intro Text
Consequences []Text
Note Text
Action string
Confirm string
KeepHref string
}
// NewInDomainDelete builds the page. hasUpstream says that an upstream host is
// saved on the domain; listed is how many recipient addresses it lists (0 when
// it accepts any address): both go with the domain, so both are named.
func NewInDomainDelete(m Meta, id int64, name string, hasUpstream bool, listed int) *InDomainDelete {
m.Title, m.Section, m.Page = "Delete "+name, "inbound", "domains"
href := InboundDomainHref(id)
crumbs := []Crumb{{Href: inboundDomains, Label: "Inbound domains"}, {Href: href, Label: name}}
p := &InDomainDelete{
Meta: m,
Head: Head{Crumbs: crumbs, Title: "Delete " + name},
Danger: Box{Icon: "ti-alert-triangle", Title: "This cannot be undone", Variant: BoxDanger},
Intro: Rich("Deleting the inbound domain ", Strong(name), " will:"),
Consequences: []Text{
Plain("stop accepting mail for it on port 25 — senders get a relay-denied reply;"),
},
Note: Plain("Remove or repoint the MX record first, or mail for the domain will bounce. Outbound domains are not touched."),
Action: href + "/delete",
Confirm: "Delete " + name,
KeepHref: href,
}
switch {
case hasUpstream && listed > 0:
p.Consequences = append(p.Consequences, Plain("remove its upstream and its "+plural(int64(listed), "listed recipient", "listed recipients")+"."))
case hasUpstream:
p.Consequences = append(p.Consequences, Plain("remove its upstream."))
case listed > 0:
p.Consequences = append(p.Consequences, Plain("remove its "+plural(int64(listed), "listed recipient", "listed recipients")+"."))
}
return p
}
// ---- One inbound domain
// InDomain is the data of a domain's page: the MX record to publish and what
// DNS says of it, the upstream and the recipients — each its own form with its
// own POST — and what the spam filter does for the domain. The side menu leads
// to the boxes and, for the global role, to deleting the domain.
type InDomain struct {
Meta
Head Head
Flash *Flash
id int64
name string
dnsStatus string
recipientCount int
recipientMode string
filterOn bool
DNS Box
Record Record
Upstream Box
UpstreamAction string
Host string
Port string
TLSOptions []Option
TLSSelected string
Recipients Box
RecipientsAction string
ModeOptions []Option
ModeSelected string
Addresses string
RecipientsHelp Text
Filter Box
FilterText Text
FilterFacts []Fact
}
// NewInDomain builds the page of a domain. The MX record is filled by WithMX
// and the filter box by WithFilter.
func NewInDomain(m Meta, in InDomainInput) *InDomain {
m.Title, m.Section, m.Page = in.Name+" · Inbound domains", "inbound", "domains"
href := InboundDomainHref(in.ID)
return &InDomain{
Meta: m,
Head: Head{
Crumbs: []Crumb{{Href: inboundDomains, Label: "Inbound domains"}},
Title: in.Name,
Lead: inDomainLead(in),
Actions: []Action{{Label: "Re-check DNS", Icon: "ti-refresh", Post: href + "/dns-recheck"}},
},
id: in.ID, name: in.Name, dnsStatus: in.DNSStatus, recipientCount: in.RecipientCount, recipientMode: in.RecipientMode,
DNS: Box{No: "01", Title: "MX record", ID: "dns",
Help: &HelpLink{Href: "/help#inbound", Title: "Backup-MX and forwarding"}},
Upstream: Box{No: "02", Title: "Upstream", ID: "upstream", End: Plain("Not a mailbox")},
UpstreamAction: href + "/upstream",
Host: in.Host,
Port: strconv.Itoa(in.Port),
TLSOptions: []Option{{Value: "may", Label: "Opportunistic"}, {Value: tlsModeEncrypt, Label: "Required"},
{Value: tlsModeNone, Label: "Off"}},
TLSSelected: in.TLSMode,
Recipients: Box{No: "03", Title: "Valid recipients", ID: "recipients"},
RecipientsAction: href + "/recipients",
ModeOptions: []Option{{Value: "list", Label: "Listed addresses only"},
{Value: recipientModeAny, Label: "Any recipient at this domain"}},
ModeSelected: in.RecipientMode,
Addresses: strings.Join(in.Addresses, "\n"),
RecipientsHelp: Plain("One per line or comma-separated. Unknown recipients are rejected at RCPT, so this relay generates no backscatter. Prefer a list unless the upstream itself rejects unknowns."),
Filter: Box{No: "04", Title: "Spam filter", ID: "filter"},
}
}
// inDomainLead is the sentence under the title: what port 25 does with mail
// for the domain and who it accepts.
func inDomainLead(in InDomainInput) Text {
who := "no recipients listed"
switch {
case in.RecipientMode == recipientModeAny:
who = "any recipient"
case in.RecipientCount > 0:
who = plural(int64(in.RecipientCount), "recipient", "recipients")
}
if in.Host == "" {
return Plain("Not accepting mail yet: no upstream is saved · " + who)
}
tls := "over opportunistic TLS"
switch in.TLSMode {
case tlsModeEncrypt:
tls = "over required TLS"
case tlsModeNone:
tls = "without TLS"
}
return Rich("Accepted on port 25, handed to ", Mono(in.Host+":"+strconv.Itoa(in.Port)), " "+tls+" · "+who)
}
// MXRecord is the MX record to publish: it points the domain at this server
// (hostname is SELFPOST_HOSTNAME) and keeps any primary MX of its own.
func MXRecord(domain, hostname string, c DNSCheck) Record {
r := Record{Name: "MX", Note: Plain("Keep any existing primary MX if this is a backup-MX"),
Host: domain, Type: "MX", Value: "10 " + strings.TrimSuffix(hostname, ".") + "."}
applyCheck(&r, c, nil, nil)
return r
}
// WithMX sets the MX record and, from its check, the stamp under the title.
func (p *InDomain) WithMX(hostname string, c DNSCheck) *InDomain {
p.Record = MXRecord(p.name, hostname, c)
p.dnsStatus = c.Status
level := tagLevel(c.Status)
pm := &Postmark{Top: "DNS", Word: "OK", Bottom: "MX POINTS HERE", Level: level}
switch level {
case LevelFail:
pm.Word, pm.Bottom = "FAIL", "NO MX HERE"
case LevelWarn:
pm.Word, pm.Bottom = "WARN", "NOT CHECKED"
}
p.Head.Postmark = pm
return p
}
// WithFilter fills the Spam filter box from what the server is set up with:
// on says a filter is attached to port 25, milter is its address
// (INBOUND_ANTISPAM_MILTER) and action what Postfix does while it is down
// ("accept", "tempfail" or "" when that is not known). The box says only that;
// the address is the global role's, and so is the name of the variable.
func (p *InDomain) WithFilter(on bool, milter, action string) *InDomain {
p.filterOn = on
p.Filter.End = Rich(filterTag(on))
if !on {
if p.IsGlobal {
p.FilterText = Rich("Mail for this domain is passed to the upstream unfiltered; turning the filter on is a server setting (",
Code("INBOUND_ANTISPAM_MILTER"), ").")
} else {
p.FilterText = Plain("Mail for this domain is passed to the upstream unfiltered; turning the filter on is a server setting, set by the server's administrator.")
}
return p
}
p.FilterFacts = []Fact{{Label: "Applies to", Value: Plain("Every inbound domain of this server")}}
if p.IsGlobal && milter != "" {
p.FilterFacts = append(p.FilterFacts, Fact{Label: "Filter", Value: Plain(milter), Mono: true})
}
switch action {
case "accept":
p.FilterFacts = append(p.FilterFacts, Fact{Label: "If the filter is down", Value: Plain("Mail is accepted unfiltered")})
case "tempfail":
p.FilterFacts = append(p.FilterFacts, Fact{Label: "If the filter is down", Value: Plain("Mail is deferred until it answers")})
}
return p
}
// WithResult shows the result of the last action, or its refusal, between the
// head and the boxes.
func (p *InDomain) WithResult(flash, formErr string) *InDomain {
p.Flash = resultFlash(flash, formErr)
return p
}
// Menu is the side menu: this page's boxes with their state, and the entry that
// deletes the domain for the global role.
func (p *InDomain) Menu() SideMenu {
mx := Tag{Status: p.dnsStatus, Label: p.dnsStatus}
if p.dnsStatus == "error" {
mx = Tag{Status: "fail", Label: "fail"}
}
recipients := &Tag{Label: strconv.Itoa(p.recipientCount)}
if p.recipientMode == recipientModeAny {
recipients.Label = "any"
}
here := MenuGroup{Label: "This domain", Items: []MenuItem{
{Label: "MX record", Icon: "ti-world-check", Href: "#dns", Active: true, Tag: &mx},
{Label: "Upstream", Icon: "ti-arrow-forward-up", Href: "#upstream"},
{Label: "Recipients", Icon: "ti-address-book", Href: "#recipients", Tag: recipients},
{Label: "Spam filter", Icon: "ti-shield-check", Href: "#filter", Tag: filterTag(p.filterOn).Tag},
}}
menu := SideMenu{Groups: []MenuGroup{here}}
if p.IsGlobal {
menu.Groups = append(menu.Groups, MenuGroup{Label: "Rarely changed", Items: []MenuItem{
{Label: "Delete domain", Icon: "ti-trash", Href: InboundDomainHref(p.id) + "/delete", Danger: true},
}})
}
return menu
}
+115
View File
@@ -0,0 +1,115 @@
package view
import "testing"
// ---- One inbound domain
func TestInDomainShowsTheMockupsBoxes(t *testing.T) {
out := renderSignedIn(t, "in-domain", inDomainFixture())
pageHas(t, "Inbound domain", out,
`<h1 class="title is-3">lists.example.org</h1>`, `<b>OK</b>`, `<span>MX POINTS HERE</span>`,
`Accepted on port 25, handed to <span class="sp-mono">mx.internal:25</span> over required TLS · 4 recipients`,
`action="/inbound/domains/1/dns-recheck"`, `Re-check DNS`,
`<h2>MX record</h2>`, `<span class="tag is-success is-light">published</span>`, `value="10 mail.example.org."`,
`<h2>Upstream</h2>`, `action="/inbound/domains/1/upstream"`, `name="host" value="mx.internal"`, `name="port" inputmode="numeric" value="25"`,
`name="tls_mode"`, `<option value="encrypt" selected>Required</option>`,
`<h2>Valid recipients</h2>`, `action="/inbound/domains/1/recipients"`, `name="recipient_mode" data-list-mode="list"`,
`<option value="list" selected>Listed addresses only</option>`, `name="addresses"`,
"announce@lists.example.org\ndev@lists.example.org\nowner@lists.example.org\nsecurity@lists.example.org",
`<h2>Spam filter</h2>`, `href="#filter"`, `href="/inbound/domains/1/delete"`, `Delete domain`,
)
// What has no source in this build is not drawn.
pageLacks(t, "Inbound domain", out, `rspamd`, `quarantine`, `Quarantine`, `Log for this domain`, `/inbound/log`, `/inbound/quarantine`, `Allow and deny`)
}
// The Spam filter box states what the server is set up with, and what of it the
// viewer needs: the global role also gets the milter address and the name of the
// variable that turns the filter on.
func TestInDomainSpamFilterBoxInTheFourCases(t *testing.T) {
domain := func(m Meta, on bool, milter, action string) string {
p := NewInDomain(m, InDomainInput{ID: 1, Name: "lists.example.org", DNSStatus: "ok", Host: "mx.internal", Port: 25,
TLSMode: "may", RecipientMode: "any"}).
WithMX("mail.example.org", DNSCheck{Status: "ok"}).WithFilter(on, milter, action)
return renderSignedIn(t, "in-domain", p)
}
ops := Meta{User: "ops", HasInbound: true}
out := domain(admin(), true, "inet:antispam:11332", "accept")
pageHas(t, "filter on, global", out, `<span class="tag is-success is-light">on</span>`,
`<dt>Applies to</dt><dd>Every inbound domain of this server</dd>`,
`<dt>Filter</dt><dd class="sp-mono">inet:antispam:11332</dd>`,
`<dt>If the filter is down</dt><dd>Mail is accepted unfiltered</dd>`)
out = domain(ops, true, "inet:antispam:11332", "tempfail")
pageHas(t, "filter on, domain role", out, `<dt>Applies to</dt>`, `Mail is deferred until it answers`)
pageLacks(t, "filter on, domain role", out, `inet:antispam`, `<dt>Filter</dt>`, `Delete domain`, `Rarely changed`)
out = domain(admin(), false, "", "")
pageHas(t, "filter off, global", out, `<span class="tag is-light">off</span>`,
`Mail for this domain is passed to the upstream unfiltered; turning the filter on is a server setting (<code>INBOUND_ANTISPAM_MILTER</code>).`)
pageLacks(t, "filter off, global", out, `<dl class="sp-facts">`, `Applies to`)
out = domain(ops, false, "", "")
pageHas(t, "filter off, domain role", out, `turning the filter on is a server setting, set by the server&#39;s administrator.`)
pageLacks(t, "filter off, domain role", out, `INBOUND_ANTISPAM_MILTER`)
// An action nobody can name is not guessed at.
out = domain(admin(), true, "unix:/run/spam.sock", "")
pageHas(t, "filter on, unknown action", out, `unix:/run/spam.sock`)
pageLacks(t, "filter on, unknown action", out, `If the filter is down`)
}
// The stamp, the record's tag and the menu say the same about the MX check.
func TestInDomainMXStates(t *testing.T) {
page := func(c DNSCheck) string {
p := NewInDomain(admin(), InDomainInput{ID: 1, Name: "lists.example.org", DNSStatus: c.Status, Host: "mx.internal", Port: 25, TLSMode: "may"}).
WithMX("mail.example.org", c).WithFilter(false, "", "")
return renderSignedIn(t, "in-domain", p)
}
out := page(DNSCheck{Status: "error", Detail: "No MX points at mail.example.org (this server).", Found: []string{"10 mx.other.net."}})
pageHas(t, "mismatch", out, `<b>FAIL</b>`, `NO MX HERE`, `sp-fail`, `<span class="tag is-danger is-light">mismatch</span>`,
`value="10 mx.other.net."`, `No MX points at mail.example.org (this server).`, `<span class="tag is-danger is-light">fail</span>`)
out = page(DNSCheck{Status: "error", Detail: "No MX record is published at lists.example.org."})
pageHas(t, "missing", out, `<span class="tag is-danger is-light">missing</span>`, `No MX record is published at lists.example.org.`)
pageLacks(t, "missing", out, `In DNS now`)
out = page(DNSCheck{Status: "unknown"})
pageHas(t, "unknown", out, `<b>WARN</b>`, `NOT CHECKED`, `not checked</span>`, `<span class="tag is-light">unknown</span>`)
}
// A domain with nothing saved yet says so in its lead; any-recipient mode shows
// an empty list, and what was refused is a flash above the boxes.
func TestInDomainNewAndRefused(t *testing.T) {
p := NewInDomain(admin(), InDomainInput{ID: 3, Name: "new.example.org", DNSStatus: "unknown", Host: "", Port: 25, TLSMode: "may", RecipientMode: "list"}).
WithMX("mail.example.org", DNSCheck{Status: "unknown"}).WithFilter(false, "", "").WithResult("", "Host is required.")
out := renderSignedIn(t, "in-domain", p)
pageHas(t, "new domain", out, `Not accepting mail yet: no upstream is saved · no recipients listed`,
`<div class="notification is-danger is-light">`, `Host is required.`, `<span class="tag is-light">0</span>`)
p = NewInDomain(admin(), InDomainInput{ID: 3, Name: "new.example.org", Host: "h", Port: 25, TLSMode: "none", RecipientMode: "any"}).
WithMX("mail.example.org", DNSCheck{Status: "ok"}).WithFilter(false, "", "").WithResult("Upstream saved.", "")
out = renderSignedIn(t, "in-domain", p)
pageHas(t, "any recipient", out, `without TLS · any recipient`, `<option value="any" selected>Any recipient at this domain</option>`,
`<option value="none" selected>Off</option>`, `<span class="tag is-light">any</span>`, `<div class="notification is-success is-light">`, `Upstream saved.`)
}
func TestInDomainEscapesWhatPeopleTyped(t *testing.T) {
p := NewInDomain(admin(), InDomainInput{ID: 1, Name: `<script>alert(1)</script>`, Host: `"><img src=x>`, Port: 25, TLSMode: "may",
RecipientMode: "list", Addresses: []string{`</textarea><b>x</b>`}}).
WithMX("mail.example.org", DNSCheck{Status: "error", Found: []string{`<i>x</i>`}}).WithFilter(true, `inet:<u>:1`, "accept").WithResult("", `<s>bad</s>`)
out := renderSignedIn(t, "in-domain", p)
pageLacks(t, "Inbound domain", out, `<script>alert(1)`, `<img src=x>`, `</textarea><b>`, `<i>x</i>`, `<u>`, `<s>bad`)
}
// The list's head says whether the instance filters.
func TestInDomainsHeadStatesTheFilter(t *testing.T) {
out := renderSignedIn(t, "in-domains", inDomainsFixture())
pageHas(t, "Inbound domains", out, `Spam filter <span class="tag is-success is-light">on</span> · 2 domains`)
p := NewInDomains(admin(), true).WithFilter(false).WithRows(nil)
pageHas(t, "Inbound domains", renderSignedIn(t, "in-domains", p), `Spam filter <span class="tag is-light">off</span> · 0 domains`)
// Either order of the two calls says the same.
p = NewInDomains(admin(), true).WithRows(nil).WithFilter(false)
pageHas(t, "Inbound domains", renderSignedIn(t, "in-domains", p), `Spam filter <span class="tag is-light">off</span> · 0 domains`)
}
+99
View File
@@ -0,0 +1,99 @@
package view
import (
"strings"
"testing"
)
// ---- Inbound domains
// The global role adds and deletes domains; a domain administrator sees the
// list alone, so the page has no add box, no Delete link, and numbers the table
// box 01.
func TestInDomainsOffersAddAndDeleteToTheGlobalRoleOnly(t *testing.T) {
out := renderSignedIn(t, "in-domains", inDomainsFixture())
pageHas(t, "Inbound domains", out,
`<h1 class="title is-3">Inbound domains</h1>`, `<h2>Add a domain</h2>`, `action="/inbound/domains"`, `name="name"`,
`2 domains`, `<th>TLS to upstream</th>`, `href="/inbound/domains/1"><strong>lists.example.org</strong>`,
`<span class="tag is-success is-light">MX</span>`, `>mx.internal:25<`, `4 addresses`, `any address`, `>10.0.4.12:2525<`,
`<span class="tag is-success is-light">required</span>`, `<span class="tag is-light">opportunistic</span>`,
`>Delete</a>`, `href="/inbound/domains/2/delete"`,
`The MX tag is green when at least one MX of the domain points at this server.`,
)
p := NewInDomains(Meta{User: "ops", HasInbound: true}, false).WithRows([]InDomainRow{
NewInDomainRow(InDomainInput{ID: 1, Name: "lists.example.org", DNSStatus: "ok", Host: "mx.internal", Port: 25,
TLSMode: "encrypt", RecipientMode: "list", RecipientCount: 1}, false),
})
out = renderSignedIn(t, "in-domains", p)
pageHas(t, "Inbound domains", out, `<span class="sp-no">01</span><h2>Domains</h2>`, `1 domain`, `1 address<`)
pageLacks(t, "Inbound domains", out, `Add a domain`, `name="name"`, `>Delete</a>`, `/delete`)
}
// The MX tag takes its colour from the check and its word stays MX; an upstream
// not yet saved is a dash and TLS that is off is not dressed up as fine.
func TestInDomainRowsWordTheStoresFields(t *testing.T) {
p := NewInDomains(admin(), true).WithRows([]InDomainRow{
NewInDomainRow(InDomainInput{ID: 7, Name: "new.example.org", DNSStatus: "error", TLSMode: "none", RecipientMode: "list"}, true),
NewInDomainRow(InDomainInput{ID: 8, Name: "warn.example.org", DNSStatus: "warn", Host: "h", Port: 25, TLSMode: "may", RecipientMode: "list", RecipientCount: 2}, true),
NewInDomainRow(InDomainInput{ID: 9, Name: "unk.example.org", DNSStatus: "unknown", TLSMode: "may", RecipientMode: "any"}, true),
})
out := renderSignedIn(t, "in-domains", p)
pageHas(t, "Inbound domains", out,
`<span class="tag is-danger is-light">MX</span>`, `<span class="tag is-warning is-light">MX</span>`, `<span class="tag is-light">MX</span>`,
`>—<`, `0 addresses`, `<span class="tag is-light">off</span>`, `2 addresses`, `3 domains`)
}
func TestInDomainsEmptyAndRefused(t *testing.T) {
p := NewInDomains(admin(), true).WithRows(nil).WithResult("", "That inbound domain is already configured.", "lists.example.org")
out := renderSignedIn(t, "in-domains", p)
pageHas(t, "Inbound domains", out, `No inbound domains yet. Add the first one above`,
`<div class="notification is-danger is-light">`, `That inbound domain is already configured.`, `value="lists.example.org"`, `0 domains`)
pageLacks(t, "Inbound domains", out, `<table`)
// the flash sits between the head and the first box
if strings.Index(out, `class="notification`) < strings.Index(out, `class="sp-head"`) ||
strings.Index(out, `class="notification`) > strings.Index(out, `class="box"`) {
t.Error("the flash is not between page_head and the first box")
}
out = renderSignedIn(t, "in-domains", NewInDomains(admin(), true).WithResult("Inbound domain deleted.", "", ""))
pageHas(t, "Inbound domains", out, `<div class="notification is-success is-light">`, `Inbound domain deleted.`)
// A domain administrator with nothing listed is not told to add anything.
out = renderSignedIn(t, "in-domains", NewInDomains(Meta{User: "ops", HasInbound: true}, false))
pageHas(t, "Inbound domains", out, `No inbound domains yet.`)
pageLacks(t, "Inbound domains", out, `Add the first one`)
}
func TestInDomainsEscapesWhatPeopleTyped(t *testing.T) {
p := NewInDomains(admin(), true).WithRows([]InDomainRow{
NewInDomainRow(InDomainInput{ID: 1, Name: `<script>alert(1)</script>`, Host: `"><img src=x>`, Port: 25}, true),
}).WithResult("", "bad", `"><i>`)
out := renderSignedIn(t, "in-domains", p)
pageLacks(t, "Inbound domains", out, `<script>alert(1)`, `<img src=x>`, `<i>`)
}
// ---- Delete an inbound domain
func TestInDomainDeleteNamesWhatGoesWithIt(t *testing.T) {
out := renderSignedIn(t, "in-domain-delete", inDomainDeleteFixture())
pageHas(t, "Delete", out,
`<h1 class="title is-3">Delete lists.example.org</h1>`, `<div class="box sp-danger-zone">`, `<h2>This cannot be undone</h2>`,
`Deleting the inbound domain <strong>lists.example.org</strong> will:`,
`stop accepting mail for it on port 25 — senders get a relay-denied reply;`,
`remove its upstream and its 4 listed recipients.`, `Outbound domains are not touched.`,
`<form method="post" action="/inbound/domains/1/delete">`, `class="button is-danger"`, `Delete lists.example.org</button>`,
`href="/inbound/domains/1">Keep it</a>`,
)
// Only what the domain really has is named.
out = renderSignedIn(t, "in-domain-delete", NewInDomainDelete(admin(), 1, "lists.example.org", true, 0))
pageHas(t, "Delete", out, `remove its upstream.`)
pageLacks(t, "Delete", out, `recipient`)
out = renderSignedIn(t, "in-domain-delete", NewInDomainDelete(admin(), 1, "lists.example.org", false, 1))
pageHas(t, "Delete", out, `remove its 1 listed recipient.`)
pageLacks(t, "Delete", out, `upstream`)
out = renderSignedIn(t, "in-domain-delete", NewInDomainDelete(admin(), 1, "lists.example.org", false, 0))
pageHas(t, "Delete", out, `stop accepting mail for it`)
pageLacks(t, "Delete", out, `upstream`, `recipient`)
}
-3
View File
@@ -12,9 +12,6 @@
# <name> a page of the view engine that is still the old template
backup
help
inbound
inbound_delete
inbound_domain
mail_queue
system_log
user_delete
@@ -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}}
+7 -3
View File
@@ -52,9 +52,9 @@ var pageFiles = map[string][]string{
"out-app": {"templates/out_app.html"},
"out-app-created": {"templates/out_app_created.html"},
"out-domain-delete": {"templates/out_domain_delete.html"},
"inbound": {"templates/inbound.html"},
"inbound_domain": {"templates/inbound_domain.html"},
"inbound_delete": {"templates/inbound_delete.html"},
"in-domains": {"templates/in_domains.html"},
"in-domain": {"templates/in_domain.html"},
"in-domain-delete": {"templates/in_domain_delete.html"},
"out-log": {"templates/out_log.html", "templates/out_log_rows.html"},
"out-message": {"templates/out_message.html"},
"dmarc": {"templates/dmarc.html"},
@@ -88,6 +88,10 @@ var kitPages = map[string]bool{
"out-app-created": true,
"out-domain-delete": true,
"in-domains": true,
"in-domain": true,
"in-domain-delete": true,
"out-log": true,
"out-message": true,
"dmarc": true,