diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fdb818..4e5b70f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,6 +99,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version in the new design. The log refreshes its rows without touching the filter being chosen. `deliveries`, `delivery`, `dmarc`, `dmarc_domain` and `dmarc_report` are off the ratchet. +- panel-redesign (stage 2): the **Inbound** pages are in the new design: the + domain list, the domain page (MX record, upstream, valid recipients) and + the delete confirmation. Every inbound domain page has a **Spam filter** + box and the list names the filter's state: the panel reads + `INBOUND_ANTISPAM_MILTER` and `INBOUND_ANTISPAM_MILTER_ACTION` and says + whether inbound mail is filtered and what happens while the filter is + down. The filter's address is shown to the global role only. `inbound`, + `inbound_domain` and `inbound_delete` are off the ratchet. ### Changed diff --git a/cmd/panel/antispam_test.go b/cmd/panel/antispam_test.go new file mode 100644 index 0000000..e892005 --- /dev/null +++ b/cmd/panel/antispam_test.go @@ -0,0 +1,34 @@ +package main + +import "testing" + +// The panel reads the inbound filter's two variables with the semantics of +// build/postfix-config.sh: the action is accept unless it says tempfail, and a +// value the script would refuse is not guessed at. +func TestAntispamActionMirrorsTheBuildScript(t *testing.T) { + for raw, want := range map[string]string{ + "": "accept", "accept": "accept", "tempfail": "tempfail", + "reject": "", "Accept": "", "TEMPFAIL": "", " accept": "", + } { + if got := antispamAction(raw); got != want { + t.Errorf("antispamAction(%q) = %q, want %q", raw, got, want) + } + } +} + +func TestLoadConfigReadsTheInboundFilter(t *testing.T) { + t.Setenv("INBOUND_RELAY_ENABLE", "true") + t.Setenv("INBOUND_ANTISPAM_MILTER", "inet:antispam:11332") + t.Setenv("INBOUND_ANTISPAM_MILTER_ACTION", "tempfail") + cfg := loadConfig() + if !cfg.inboundEnabled || cfg.inboundAntispamMilter != "inet:antispam:11332" || cfg.inboundAntispamAction != "tempfail" { + t.Errorf("config = %+v", cfg) + } + + t.Setenv("INBOUND_ANTISPAM_MILTER", "") + t.Setenv("INBOUND_ANTISPAM_MILTER_ACTION", "") + cfg = loadConfig() + if cfg.inboundAntispamMilter != "" || cfg.inboundAntispamAction != "accept" { + t.Errorf("unset filter: milter %q, action %q", cfg.inboundAntispamMilter, cfg.inboundAntispamAction) + } +} diff --git a/cmd/panel/envdoc_test.go b/cmd/panel/envdoc_test.go index 3425f38..cfb7675 100644 --- a/cmd/panel/envdoc_test.go +++ b/cmd/panel/envdoc_test.go @@ -89,6 +89,8 @@ var loadConfigKeys = []string{ "POSTFIX_DIR", "SELFPOST_DEPLOY_ROOT", "INBOUND_RELAY_ENABLE", + "INBOUND_ANTISPAM_MILTER", + "INBOUND_ANTISPAM_MILTER_ACTION", "DMARC_REPORTS_ENABLE", } diff --git a/cmd/panel/httpserver.go b/cmd/panel/httpserver.go index 35f1d9f..5c84a76 100644 --- a/cmd/panel/httpserver.go +++ b/cmd/panel/httpserver.go @@ -110,6 +110,8 @@ func newPanel(cfg config, st *store.Store) (*web.Server, error) { RateLimitWindowSeconds: cfg.rateLimitWindowSeconds, RetryPolicy: retryPolicy, InboundEnabled: cfg.inboundEnabled, + InboundAntispamMilter: cfg.inboundAntispamMilter, + InboundAntispamAction: cfg.inboundAntispamAction, DMARCEnabled: cfg.dmarcEnabled, SendLogRetentionEnvDefault: cfg.retentionDays, }, cfg.setupTokenPath) diff --git a/cmd/panel/main.go b/cmd/panel/main.go index e50c7c9..0905ff3 100644 --- a/cmd/panel/main.go +++ b/cmd/panel/main.go @@ -91,7 +91,12 @@ type config struct { deployRoot string inboundEnabled bool - dmarcEnabled bool + // The optional milter on the inbound listener (INBOUND_ANTISPAM_MILTER) and + // what Postfix does when it is down. Read-only: build/postfix-config.sh is + // what attaches it; the panel only says so. See antispamAction. + inboundAntispamMilter string + inboundAntispamAction string + dmarcEnabled bool } func loadConfig() config { @@ -154,12 +159,28 @@ func loadConfig() config { postfixDir: envDefault("POSTFIX_DIR", filepath.Join(dataDir, "postfix")), deployRoot: envDefault("SELFPOST_DEPLOY_ROOT", "/selfpost-deploy"), // Optional inbound relay (backup-MX / forwarder). Off unless exactly "true". - inboundEnabled: os.Getenv("INBOUND_RELAY_ENABLE") == "true", + inboundEnabled: os.Getenv("INBOUND_RELAY_ENABLE") == "true", + inboundAntispamMilter: os.Getenv("INBOUND_ANTISPAM_MILTER"), + inboundAntispamAction: antispamAction(os.Getenv("INBOUND_ANTISPAM_MILTER_ACTION")), // Optional DMARC aggregate ingest on port 25. Off unless exactly "true". dmarcEnabled: os.Getenv("DMARC_REPORTS_ENABLE") == "true", } } +// antispamAction reads INBOUND_ANTISPAM_MILTER_ACTION the way +// build/postfix-config.sh does: empty means accept, and accept and tempfail are +// the only other values it lets the container start with. Anything else is +// returned as "" — the panel says nothing about the action rather than guess. +func antispamAction(raw string) string { + switch raw { + case "", "accept": + return "accept" + case "tempfail": + return "tempfail" + } + return "" +} + // saslRealm chooses the realm new SASL accounts live under. It mirrors the // hostname Postfix's SASL layer uses so a client authenticating with a bare // login resolves to the right account. diff --git a/docs/assets/panel-redesign/evidence/in-domain-delete.png b/docs/assets/panel-redesign/evidence/in-domain-delete.png new file mode 100644 index 0000000..9f554af Binary files /dev/null and b/docs/assets/panel-redesign/evidence/in-domain-delete.png differ diff --git a/docs/assets/panel-redesign/evidence/in-domain.png b/docs/assets/panel-redesign/evidence/in-domain.png new file mode 100644 index 0000000..c1d60c9 Binary files /dev/null and b/docs/assets/panel-redesign/evidence/in-domain.png differ diff --git a/docs/assets/panel-redesign/evidence/in-domains.png b/docs/assets/panel-redesign/evidence/in-domains.png new file mode 100644 index 0000000..1a4242e Binary files /dev/null and b/docs/assets/panel-redesign/evidence/in-domains.png differ diff --git a/docs/plans/panel-redesign.md b/docs/plans/panel-redesign.md index 9b20b05..62e54a3 100644 --- a/docs/plans/panel-redesign.md +++ b/docs/plans/panel-redesign.md @@ -517,7 +517,7 @@ only when both are done. Every stage-2 step ends with the evidence of - [x] Overview, Health, and the Account / Settings split — **Sonnet** - [x] Outbound: domains, domain, domain settings, application form, shown-once password, delete — **Sonnet** - [x] Outbound: log, message, DMARC hub / domain / report — **Sonnet** -- [ ] Inbound: domains, domain with the *Spam filter* box, delete — **Sonnet** +- [x] Inbound: domains, domain with the *Spam filter* box, delete — **Sonnet** - [ ] Server: system log, backup, users, user form, delete; Help as `help_topic` partials, the drawer removed — **Sonnet** - [ ] Queues: old content inside the new shell (outbound), Inbound › Queue hidden — **Sonnet** - [ ] Remove the old `panel.css`, the drawer code in `panel.js` and every class nothing references; `legacyPages` empty, vocabulary test green with no allow-list — **Sonnet** diff --git a/docs/roadmap.md b/docs/roadmap.md index 273d30b..b378292 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -39,7 +39,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md). | panel-notifications | E-mail notifications about important events | candidate | — | — | | delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | candidate | — | — | | password-reset | Password reset by e-mail | candidate | — | — | -| panel-redesign | Panel redesign (accepted mockups, design contract) | agreed | 19/27 | [plans/panel-redesign.md](plans/panel-redesign.md) | +| panel-redesign | Panel redesign (accepted mockups, design contract) | agreed | 20/27 | [plans/panel-redesign.md](plans/panel-redesign.md) | | schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [plans/panel-redesign.md](plans/panel-redesign.md) | **Recommended order** (not binding; owner, 2026-10-09: feature order follows diff --git a/internal/web/handlers/handlers.go b/internal/web/handlers/handlers.go index c8742f0..286e0d9 100644 --- a/internal/web/handlers/handlers.go +++ b/internal/web/handlers/handlers.go @@ -39,6 +39,12 @@ type Config struct { // InboundEnabled mirrors INBOUND_RELAY_ENABLE: the inbound panel and // routes exist only when this is true. InboundEnabled bool + // InboundAntispamMilter mirrors INBOUND_ANTISPAM_MILTER and + // InboundAntispamAction INBOUND_ANTISPAM_MILTER_ACTION ("accept", + // "tempfail", or "" for a value build/postfix-config.sh would refuse). The + // filter is on only when the inbound relay is on and the milter is set. + InboundAntispamMilter string + InboundAntispamAction string // DMARCEnabled mirrors DMARC_REPORTS_ENABLE. DMARCEnabled bool // SendLogRetentionEnvDefault is SEND_LOG_RETENTION_DAYS at panel start; used diff --git a/internal/web/handlers/handlers_delegation_test.go b/internal/web/handlers/handlers_delegation_test.go index 1c243a6..1d999a3 100644 --- a/internal/web/handlers/handlers_delegation_test.go +++ b/internal/web/handlers/handlers_delegation_test.go @@ -224,7 +224,7 @@ func TestInboundListShowsOnlyTheAssignedDomains(t *testing.T) { if !strings.Contains(body, "a.example.com") || strings.Contains(body, "b.example.com") { t.Errorf("the administrator of A must see A and not B:\n%s", body) } - for _, globalOnly := range []string{"Add inbound domain", "/delete"} { + for _, globalOnly := range []string{"Add a domain", "/delete"} { if strings.Contains(body, globalOnly) { t.Errorf("the list offers a domain administrator %q", globalOnly) } @@ -249,7 +249,7 @@ func TestInboundListShowsOnlyTheAssignedDomains(t *testing.T) { t.Errorf("list with %s = %d, want 404", who, rec.Code) } } - if body := list(&s.global).Body.String(); !strings.Contains(body, "Add inbound domain") || !strings.Contains(body, "b.example.com") { + if body := list(&s.global).Body.String(); !strings.Contains(body, "Add a domain") || !strings.Contains(body, "b.example.com") { t.Errorf("the global role lost the full list or the add form:\n%s", body) } } diff --git a/internal/web/handlers/handlers_inbound.go b/internal/web/handlers/handlers_inbound.go index 6630b32..b5179f3 100644 --- a/internal/web/handlers/handlers_inbound.go +++ b/internal/web/handlers/handlers_inbound.go @@ -5,7 +5,6 @@ import ( "fmt" "net/http" "strconv" - "strings" "sync" "github.com/mixeme/selfpost/internal/dnscheck" @@ -13,16 +12,9 @@ import ( "github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/web/auth" "github.com/mixeme/selfpost/internal/web/validate" + "github.com/mixeme/selfpost/internal/web/view" ) -type inboundRow struct { - store.InboundDomain - DNS health.Status - Upstream string - TLSLabel string - RcptLabel string -} - // Inbound is delegated to domain administrators separately from outbound (plan // § Who sees what), so its handlers come in three strengths: // @@ -124,34 +116,37 @@ func (h *Handlers) renderInboundList(w http.ResponseWriter, r *http.Request, sta http.Error(w, "internal error", http.StatusInternalServerError) return } - data := h.pageBase(r) - data["Title"] = "SelfPost — inbound" - data["Active"] = "inbound" - data["Domains"] = h.inboundRows(list) - data["Error"] = formErr - data["FormName"] = formName + flash := "" if r.URL.Query().Get("deleted") != "" { - data["Flash"] = "Inbound domain deleted." + flash = "Inbound domain deleted." } - h.view.Render(w, status, "inbound", data) + on, _, _ := h.inboundFilter() + page := view.NewInDomains(h.shellMeta(r), p.IsGlobal()). + WithFilter(on). + WithRows(h.inboundRows(list, p.IsGlobal())). + WithResult(flash, formErr, formName) + h.view.Render(w, status, "in-domains", page) } -func (h *Handlers) inboundRows(domains []store.InboundDomain) []inboundRow { - rows := make([]inboundRow, len(domains)) +// inboundRows is one row per inbound domain: the verdict of the MX check +// (cached for a few minutes, as on the domain page), the upstream, who is +// accepted and the TLS to the upstream. The checks run side by side. +func (h *Handlers) inboundRows(domains []store.InboundDomain, canDelete bool) []view.InDomainRow { + rows := make([]view.InDomainRow, len(domains)) var wg sync.WaitGroup for i, d := range domains { - rows[i] = inboundRow{ - InboundDomain: d, - DNS: health.StatusUnknown, - Upstream: inboundUpstream(d), - TLSLabel: tlsLabel(d.TLSMode), - RcptLabel: rcptLabel(d), + in := view.InDomainInput{ + ID: d.ID, Name: d.Name, DNSStatus: string(health.StatusUnknown), + Host: d.Host, Port: d.Port, TLSMode: d.TLSMode, + RecipientMode: d.RecipientMode, RecipientCount: d.RecipientCount, } + rows[i] = view.NewInDomainRow(in, canDelete) wg.Add(1) go func() { defer wg.Done() if h.dns != nil && h.cfg.Hostname != "" { - rows[i].DNS = h.dns.InboundMX(d.Name, h.cfg.Hostname, false).Status + in.DNSStatus = string(h.dns.InboundMX(d.Name, h.cfg.Hostname, false).Status) + rows[i] = view.NewInDomainRow(in, canDelete) } }() } @@ -159,42 +154,6 @@ func (h *Handlers) inboundRows(domains []store.InboundDomain) []inboundRow { return rows } -func inboundUpstream(d store.InboundDomain) string { - if d.Host == "" { - return "—" - } - return fmt.Sprintf("%s:%d", d.Host, d.Port) -} - -func tlsLabel(mode string) string { - switch mode { - case store.TLSModeEncrypt: - return "required" - case store.TLSModeNone: - return "off" - default: - return "opportunistic" - } -} - -func tlsStatusClass(mode string) string { - if mode == store.TLSModeEncrypt { - return "ok" - } - return "unknown" -} - -func rcptLabel(d store.InboundDomain) string { - if d.RecipientMode == store.RecipientModeAny { - return "any" - } - n := d.RecipientCount - if n == 1 { - return "1 listed" - } - return fmt.Sprintf("%d listed", n) -} - // HandleAddInbound validates the name, creates the inbound domain, and // redirects to its page. func (h *Handlers) HandleAddInbound(w http.ResponseWriter, r *http.Request) { @@ -244,21 +203,35 @@ func (h *Handlers) renderInboundDetail(w http.ResponseWriter, r *http.Request, s if h.dns != nil && h.cfg.Hostname != "" { mx = h.dns.InboundMX(d.Name, h.cfg.Hostname, false) } - data := h.pageBase(r) - data["Title"] = "SelfPost — " + d.Name - data["Active"] = "inbound" - data["Domain"] = d - data["MX"] = mx - data["MXValue"] = "10 " + strings.TrimSuffix(h.cfg.Hostname, ".") + "." - data["Hostname"] = h.cfg.Hostname - data["TLSLabel"] = tlsLabel(d.TLSMode) - data["TLSClass"] = tlsStatusClass(d.TLSMode) - data["RecipientText"] = strings.Join(d.Recipients, "\n") - data["Flash"] = inboundFlash(r) - data["FormErr"] = extra.FormErr - data["TransportErr"] = extra.TransportErr - data["RecipientErr"] = extra.RecipientErr - h.view.Render(w, status, "inbound_domain", data) + on, milter, action := h.inboundFilter() + page := view.NewInDomain(h.shellMeta(r), view.InDomainInput{ + ID: d.ID, Name: d.Name, DNSStatus: string(mx.Status), Host: d.Host, Port: d.Port, TLSMode: d.TLSMode, + RecipientMode: d.RecipientMode, RecipientCount: d.RecipientCount, Addresses: d.Recipients, + }). + WithMX(h.cfg.Hostname, view.DNSCheck{Status: string(mx.Status), Detail: mx.Detail, Found: mx.Records}). + WithFilter(on, milter, action). + WithResult(inboundFlash(r), firstNonEmpty(extra.FormErr, extra.TransportErr, extra.RecipientErr)) + h.view.Render(w, status, "in-domain", page) +} + +// inboundFilter says whether the instance filters inbound mail, and with what: +// the milter address and what Postfix does when it is down. It mirrors +// build/postfix-config.sh, which attaches the filter only when the inbound +// relay is on and a milter is set. +func (h *Handlers) inboundFilter() (on bool, milter, action string) { + if !h.cfg.InboundEnabled || h.cfg.InboundAntispamMilter == "" { + return false, "", "" + } + return true, h.cfg.InboundAntispamMilter, h.cfg.InboundAntispamAction +} + +func firstNonEmpty(ss ...string) string { + for _, s := range ss { + if s != "" { + return s + } + } + return "" } func inboundFlash(r *http.Request) string { @@ -330,12 +303,12 @@ func (h *Handlers) HandleInboundDeleteConfirm(w http.ResponseWriter, r *http.Req if !ok { return } - data := h.pageBase(r) - data["Title"] = "SelfPost — delete " + d.Name - data["Active"] = "inbound" - data["Domain"] = d - data["Upstream"] = inboundUpstream(d) - h.view.Render(w, http.StatusOK, "inbound_delete", data) + listed := 0 + if d.RecipientMode != store.RecipientModeAny { + listed = d.RecipientCount + } + h.view.Render(w, http.StatusOK, "in-domain-delete", + view.NewInDomainDelete(h.shellMeta(r), d.ID, d.Name, d.Host != "", listed)) } func (h *Handlers) HandleInboundDelete(w http.ResponseWriter, r *http.Request) { diff --git a/internal/web/handlers/handlers_inbound_domain_test.go b/internal/web/handlers/handlers_inbound_domain_test.go new file mode 100644 index 0000000..3d9ea55 --- /dev/null +++ b/internal/web/handlers/handlers_inbound_domain_test.go @@ -0,0 +1,130 @@ +package handlers + +import ( + "net/http" + "net/url" + "reflect" + "testing" +) + +// The page of one inbound domain is filled from the store, the DNS checker and +// the filter settings; each form's refusal comes back on it as a flash. +func TestInboundDomainPageFromTheStore(t *testing.T) { + h, st := inboundHandlers(t) + d, err := st.AddInboundDomain("lists.example.com") + if err != nil { + t.Fatal(err) + } + id := itoa(d.ID) + if err := h.inbound.SetTransport(d.ID, "mx.internal", "2525", "encrypt"); err != nil { + t.Fatal(err) + } + if err := h.inbound.SetRecipients(d.ID, "list", []string{"a@lists.example.com", "b@lists.example.com"}); err != nil { + t.Fatal(err) + } + + body := inboundCall(h, http.MethodGet, "/inbound/domains/"+id, nil, globalPrincipal).Body.String() + has(t, "domain page", body, `

lists.example.com

`, + `handed to mx.internal:2525 over required TLS · 2 recipients`, + `value="10 mail.example.org."`, `name="host" value="mx.internal"`, `value="2525"`, + ``, "a@lists.example.com\nb@lists.example.com", + `action="/inbound/domains/`+id+`/upstream"`, `action="/inbound/domains/`+id+`/recipients"`, + `action="/inbound/domains/`+id+`/dns-recheck"`, `href="/inbound/domains/`+id+`/delete"`) + + rec := inboundCall(h, http.MethodPost, "/inbound/domains/"+id+"/upstream", url.Values{ + "host": {""}, "port": {"25"}, "tls_mode": {"may"}}, globalPrincipal) + if rec.Code != http.StatusBadRequest { + t.Fatalf("empty host = %d, want 400", rec.Code) + } + has(t, "refused upstream", rec.Body.String(), `
`, `name="host" value="mx.internal"`) + + rec = inboundCall(h, http.MethodPost, "/inbound/domains/"+id+"/recipients", url.Values{ + "recipient_mode": {"list"}, "addresses": {"x@other.example"}}, globalPrincipal) + if rec.Code != http.StatusBadRequest { + t.Fatalf("foreign recipient = %d, want 400", rec.Code) + } + has(t, "refused recipients", rec.Body.String(), `
`, "a@lists.example.com\nb@lists.example.com") + + body = send(h.HandleInboundDetail, &globalPrincipal, http.MethodGet, "/inbound/domains/"+id+"?saved=1", map[string]string{"id": id}, nil).Body.String() + has(t, "after saving", body, `
`, `Upstream saved.`) +} + +// Saving either form untouched, with the values the page sends back, stores +// exactly what was stored. +func TestInboundDomainFormsSaveUntouchedUnchanged(t *testing.T) { + h, st := inboundHandlers(t) + d, err := st.AddInboundDomain("lists.example.com") + if err != nil { + t.Fatal(err) + } + id := itoa(d.ID) + if err := h.inbound.SetTransport(d.ID, "mx.internal", "2525", "none"); err != nil { + t.Fatal(err) + } + if err := h.inbound.SetRecipients(d.ID, "list", []string{"a@lists.example.com", "b@lists.example.com"}); err != nil { + t.Fatal(err) + } + before, err := st.GetInboundDomain(d.ID) + if err != nil { + t.Fatal(err) + } + + if rec := inboundCall(h, http.MethodPost, "/inbound/domains/"+id+"/upstream", url.Values{ + "host": {before.Host}, "port": {itoa(int64(before.Port))}, "tls_mode": {before.TLSMode}}, globalPrincipal); rec.Code != http.StatusSeeOther { + t.Fatalf("upstream = %d %s", rec.Code, rec.Body.String()) + } + if rec := inboundCall(h, http.MethodPost, "/inbound/domains/"+id+"/recipients", url.Values{ + "recipient_mode": {before.RecipientMode}, "addresses": {"a@lists.example.com\nb@lists.example.com"}}, globalPrincipal); rec.Code != http.StatusSeeOther { + t.Fatalf("recipients = %d %s", rec.Code, rec.Body.String()) + } + after, err := st.GetInboundDomain(d.ID) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(before, after) { + t.Errorf("saving untouched changed the domain:\nbefore %+v\nafter %+v", before, after) + } +} + +// What the Spam filter box says follows the settings the panel read, and the +// address of the filter is the global role's alone. +func TestInboundDomainSpamFilterBox(t *testing.T) { + s := newInboundStand(t) + id := itoa(s.a.ID) + get := func(ownsA bool) string { + pr := &s.global + if ownsA { + pr = &s.ownsA + } + rec := send(s.h.HandleInboundDetail, pr, http.MethodGet, "/inbound/domains/"+id, map[string]string{"id": id}, nil) + if rec.Code != http.StatusOK { + t.Fatalf("detail = %d", rec.Code) + } + return rec.Body.String() + } + + body := get(false) + has(t, "filter off", body, `turning the filter on is a server setting (INBOUND_ANTISPAM_MILTER)`) + lacks(t, "filter off", body, `Applies to`) + has(t, "filter off, domain administrator", get(true), `set by the server's administrator`) + lacks(t, "filter off, domain administrator", get(true), `INBOUND_ANTISPAM_MILTER`) + + s.h.cfg.InboundAntispamMilter, s.h.cfg.InboundAntispamAction = "inet:antispam:11332", "tempfail" + body = get(false) + has(t, "filter on", body, `Every inbound domain of this server`, `inet:antispam:11332`, `Mail is deferred until it answers`) + body = get(true) + has(t, "filter on, domain administrator", body, `Every inbound domain of this server`, `Mail is deferred until it answers`) + lacks(t, "filter on, domain administrator", body, `inet:antispam`, `/delete`) + + s.h.cfg.InboundAntispamAction = "accept" + has(t, "filter on, accept", get(false), `Mail is accepted unfiltered`) + s.h.cfg.InboundAntispamAction = "" + lacks(t, "filter on, action unknown", get(false), `If the filter is down`) + + // The list says the same in its head. + list := send(s.h.HandleInboundList, &s.global, http.MethodGet, "/inbound/domains", nil, nil).Body.String() + has(t, "list", list, `Spam filter on · 2 domains`) + s.h.cfg.InboundAntispamMilter = "" + list = send(s.h.HandleInboundList, &s.global, http.MethodGet, "/inbound/domains", nil, nil).Body.String() + has(t, "list", list, `Spam filter off · 2 domains`) +} diff --git a/internal/web/handlers/handlers_inbound_test.go b/internal/web/handlers/handlers_inbound_test.go index 20f4615..0094c6e 100644 --- a/internal/web/handlers/handlers_inbound_test.go +++ b/internal/web/handlers/handlers_inbound_test.go @@ -82,7 +82,7 @@ func TestInboundListAndAdd(t *testing.T) { if rec.Code != http.StatusOK { t.Fatalf("list = %d\n%s", rec.Code, rec.Body.String()) } - if !strings.Contains(rec.Body.String(), "Add inbound domain") { + if !strings.Contains(rec.Body.String(), "Add a domain") { t.Fatal("list missing add form") } @@ -158,3 +158,72 @@ func TestInboundDelete(t *testing.T) { t.Fatal("domain still present") } } + +// The list and the confirmation are the kit pages: the handlers fill them from +// what is stored, and a refused add comes back on the list with what was typed. +func TestInboundListPageFromTheStore(t *testing.T) { + h, st := inboundHandlers(t) + d, err := st.AddInboundDomain("lists.example.com") + if err != nil { + t.Fatal(err) + } + if err := h.inbound.SetTransport(d.ID, "mx.internal", "2525", "encrypt"); err != nil { + t.Fatal(err) + } + if _, err := st.AddInboundDomain("bare.example.com"); err != nil { + t.Fatal(err) + } + + body := inboundCall(h, http.MethodGet, "/inbound/domains", nil, globalPrincipal).Body.String() + has(t, "list", body, `

Inbound domains

`, `

Add a domain

`, `action="/inbound/domains"`, + `lists.example.com`, `>mx.internal:2525<`, `required`, `opportunistic`, `>—<`, + `0 addresses`, `href="/inbound/domains/`+itoa(d.ID)+`/delete"`, `2 domains`, `>MX`) + + rec := inboundCall(h, http.MethodPost, "/inbound/domains", url.Values{"name": {"lists.example.com"}}, globalPrincipal) + if rec.Code != http.StatusConflict { + t.Fatalf("duplicate add = %d, want 409", rec.Code) + } + has(t, "refused add", rec.Body.String(), `
`, + `That inbound domain is already configured.`, `value="lists.example.com"`) + + rec = inboundCall(h, http.MethodPost, "/inbound/domains", url.Values{"name": {"not a domain"}}, globalPrincipal) + if rec.Code != http.StatusBadRequest { + t.Fatalf("invalid add = %d, want 400", rec.Code) + } + has(t, "invalid add", rec.Body.String(), `notification is-danger`, `value="not a domain"`) + + body = send(h.HandleInboundList, &globalPrincipal, http.MethodGet, "/inbound/domains?deleted=1", nil, nil).Body.String() + has(t, "after a delete", body, `notification is-success`, `Inbound domain deleted.`) +} + +func TestInboundDeletePageNamesWhatGoesWithTheDomain(t *testing.T) { + h, st := inboundHandlers(t) + d, err := st.AddInboundDomain("lists.example.com") + if err != nil { + t.Fatal(err) + } + id := itoa(d.ID) + + body := inboundCall(h, http.MethodGet, "/inbound/domains/"+id+"/delete", nil, globalPrincipal).Body.String() + has(t, "delete page", body, `

Delete lists.example.com

`, `sp-danger-zone`, + `stop accepting mail for it on port 25`, `action="/inbound/domains/`+id+`/delete"`, + `href="/inbound/domains/`+id+`">Keep it`) + lacks(t, "delete page of a domain with no upstream and no recipients", body, `upstream`, `recipient`) + + if err := h.inbound.SetTransport(d.ID, "mx.internal", "25", "may"); err != nil { + t.Fatal(err) + } + if err := h.inbound.SetRecipients(d.ID, "list", []string{"a@lists.example.com", "b@lists.example.com"}); err != nil { + t.Fatal(err) + } + body = inboundCall(h, http.MethodGet, "/inbound/domains/"+id+"/delete", nil, globalPrincipal).Body.String() + has(t, "delete page", body, `remove its upstream and its 2 listed recipients.`) + + // Recipients are only a list while the domain accepts listed addresses. + if err := h.inbound.SetRecipients(d.ID, "any", nil); err != nil { + t.Fatal(err) + } + body = inboundCall(h, http.MethodGet, "/inbound/domains/"+id+"/delete", nil, globalPrincipal).Body.String() + has(t, "delete page, any recipient", body, `remove its upstream.`) + lacks(t, "delete page, any recipient", body, `recipient`) +} diff --git a/internal/web/handlers/handlers_status.go b/internal/web/handlers/handlers_status.go index fe90073..381539e 100644 --- a/internal/web/handlers/handlers_status.go +++ b/internal/web/handlers/handlers_status.go @@ -48,10 +48,9 @@ func (h *Handlers) HandleOverview(w http.ResponseWriter, r *http.Request) { http.Error(w, "internal error", http.StatusInternalServerError) return } - for _, row := range h.inboundRows(list) { + for _, row := range h.inboundRows(list, false) { page.InboundRows = append(page.InboundRows, view.OverviewInbound{ - Name: row.Name, Href: fmt.Sprintf("/inbound/domains/%d", row.ID), - MX: view.Tag{Status: string(row.DNS), Label: "MX"}, Upstream: row.Upstream, + Name: row.Name, Href: row.Href, MX: row.DNS, Upstream: row.Upstream, }) } } diff --git a/internal/web/view/fixtures_test.go b/internal/web/view/fixtures_test.go index 5606f87..4a70c61 100644 --- a/internal/web/view/fixtures_test.go +++ b/internal/web/view/fixtures_test.go @@ -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 +} diff --git a/internal/web/view/inbound.go b/internal/web/view/inbound.go new file mode 100644 index 0000000..7c63cfb --- /dev/null +++ b/internal/web/view/inbound.go @@ -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 +} diff --git a/internal/web/view/inbound_domain_test.go b/internal/web/view/inbound_domain_test.go new file mode 100644 index 0000000..6f93930 --- /dev/null +++ b/internal/web/view/inbound_domain_test.go @@ -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, + `

lists.example.org

`, `OK`, `MX POINTS HERE`, + `Accepted on port 25, handed to mx.internal:25 over required TLS · 4 recipients`, + `action="/inbound/domains/1/dns-recheck"`, `Re-check DNS`, + `

MX record

`, `published`, `value="10 mail.example.org."`, + `

Upstream

`, `action="/inbound/domains/1/upstream"`, `name="host" value="mx.internal"`, `name="port" inputmode="numeric" value="25"`, + `name="tls_mode"`, ``, + `

Valid recipients

`, `action="/inbound/domains/1/recipients"`, `name="recipient_mode" data-list-mode="list"`, + ``, `name="addresses"`, + "announce@lists.example.org\ndev@lists.example.org\nowner@lists.example.org\nsecurity@lists.example.org", + `

Spam filter

`, `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, `on`, + `
Applies to
Every inbound domain of this server
`, + `
Filter
inet:antispam:11332
`, + `
If the filter is down
Mail is accepted unfiltered
`) + + out = domain(ops, true, "inet:antispam:11332", "tempfail") + pageHas(t, "filter on, domain role", out, `
Applies to
`, `Mail is deferred until it answers`) + pageLacks(t, "filter on, domain role", out, `inet:antispam`, `
Filter
`, `Delete domain`, `Rarely changed`) + + out = domain(admin(), false, "", "") + pageHas(t, "filter off, global", out, `off`, + `Mail for this domain is passed to the upstream unfiltered; turning the filter on is a server setting (INBOUND_ANTISPAM_MILTER).`) + pageLacks(t, "filter off, global", out, `
`, `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, `FAIL`, `NO MX HERE`, `sp-fail`, `mismatch`, + `value="10 mx.other.net."`, `No MX points at mail.example.org (this server).`, `fail`) + + out = page(DNSCheck{Status: "error", Detail: "No MX record is published at lists.example.org."}) + pageHas(t, "missing", out, `missing`, `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, `WARN`, `NOT CHECKED`, `not checked`, `unknown`) +} + +// 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`, + `
`, `Host is required.`, `0`) + + 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`, ``, + ``, `any`, `
`, `Upstream saved.`) +} + +func TestInDomainEscapesWhatPeopleTyped(t *testing.T) { + p := NewInDomain(admin(), InDomainInput{ID: 1, Name: ``, Host: `">`, Port: 25, TLSMode: "may", + RecipientMode: "list", Addresses: []string{`x`}}). + WithMX("mail.example.org", DNSCheck{Status: "error", Found: []string{`x`}}).WithFilter(true, `inet::1`, "accept").WithResult("", `bad`) + out := renderSignedIn(t, "in-domain", p) + pageLacks(t, "Inbound domain", out, ``, Host: `">`, Port: 25}, true), + }).WithResult("", "bad", `">`) + out := renderSignedIn(t, "in-domains", p) + pageLacks(t, "Inbound domains", out, `