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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'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`)
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user