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:
@@ -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
|
in the new design. The log refreshes its rows without touching the filter
|
||||||
being chosen. `deliveries`, `delivery`, `dmarc`, `dmarc_domain` and
|
being chosen. `deliveries`, `delivery`, `dmarc`, `dmarc_domain` and
|
||||||
`dmarc_report` are off the ratchet.
|
`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
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,6 +89,8 @@ var loadConfigKeys = []string{
|
|||||||
"POSTFIX_DIR",
|
"POSTFIX_DIR",
|
||||||
"SELFPOST_DEPLOY_ROOT",
|
"SELFPOST_DEPLOY_ROOT",
|
||||||
"INBOUND_RELAY_ENABLE",
|
"INBOUND_RELAY_ENABLE",
|
||||||
|
"INBOUND_ANTISPAM_MILTER",
|
||||||
|
"INBOUND_ANTISPAM_MILTER_ACTION",
|
||||||
"DMARC_REPORTS_ENABLE",
|
"DMARC_REPORTS_ENABLE",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,8 @@ func newPanel(cfg config, st *store.Store) (*web.Server, error) {
|
|||||||
RateLimitWindowSeconds: cfg.rateLimitWindowSeconds,
|
RateLimitWindowSeconds: cfg.rateLimitWindowSeconds,
|
||||||
RetryPolicy: retryPolicy,
|
RetryPolicy: retryPolicy,
|
||||||
InboundEnabled: cfg.inboundEnabled,
|
InboundEnabled: cfg.inboundEnabled,
|
||||||
|
InboundAntispamMilter: cfg.inboundAntispamMilter,
|
||||||
|
InboundAntispamAction: cfg.inboundAntispamAction,
|
||||||
DMARCEnabled: cfg.dmarcEnabled,
|
DMARCEnabled: cfg.dmarcEnabled,
|
||||||
SendLogRetentionEnvDefault: cfg.retentionDays,
|
SendLogRetentionEnvDefault: cfg.retentionDays,
|
||||||
}, cfg.setupTokenPath)
|
}, cfg.setupTokenPath)
|
||||||
|
|||||||
+23
-2
@@ -91,7 +91,12 @@ type config struct {
|
|||||||
deployRoot string
|
deployRoot string
|
||||||
|
|
||||||
inboundEnabled bool
|
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 {
|
func loadConfig() config {
|
||||||
@@ -154,12 +159,28 @@ func loadConfig() config {
|
|||||||
postfixDir: envDefault("POSTFIX_DIR", filepath.Join(dataDir, "postfix")),
|
postfixDir: envDefault("POSTFIX_DIR", filepath.Join(dataDir, "postfix")),
|
||||||
deployRoot: envDefault("SELFPOST_DEPLOY_ROOT", "/selfpost-deploy"),
|
deployRoot: envDefault("SELFPOST_DEPLOY_ROOT", "/selfpost-deploy"),
|
||||||
// Optional inbound relay (backup-MX / forwarder). Off unless exactly "true".
|
// 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".
|
// Optional DMARC aggregate ingest on port 25. Off unless exactly "true".
|
||||||
dmarcEnabled: os.Getenv("DMARC_REPORTS_ENABLE") == "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
|
// 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
|
// hostname Postfix's SASL layer uses so a client authenticating with a bare
|
||||||
// login resolves to the right account.
|
// login resolves to the right account.
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
@@ -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] Overview, Health, and the Account / Settings split — **Sonnet**
|
||||||
- [x] Outbound: domains, domain, domain settings, application form, shown-once password, delete — **Sonnet**
|
- [x] Outbound: domains, domain, domain settings, application form, shown-once password, delete — **Sonnet**
|
||||||
- [x] Outbound: log, message, DMARC hub / domain / report — **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**
|
- [ ] 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**
|
- [ ] 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**
|
- [ ] 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**
|
||||||
|
|||||||
+1
-1
@@ -39,7 +39,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
|
|||||||
| panel-notifications | E-mail notifications about important events | candidate | — | — |
|
| 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 | — | — |
|
| delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | candidate | — | — |
|
||||||
| password-reset | Password reset by e-mail | 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) |
|
| 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
|
**Recommended order** (not binding; owner, 2026-10-09: feature order follows
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ type Config struct {
|
|||||||
// InboundEnabled mirrors INBOUND_RELAY_ENABLE: the inbound panel and
|
// InboundEnabled mirrors INBOUND_RELAY_ENABLE: the inbound panel and
|
||||||
// routes exist only when this is true.
|
// routes exist only when this is true.
|
||||||
InboundEnabled bool
|
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 mirrors DMARC_REPORTS_ENABLE.
|
||||||
DMARCEnabled bool
|
DMARCEnabled bool
|
||||||
// SendLogRetentionEnvDefault is SEND_LOG_RETENTION_DAYS at panel start; used
|
// SendLogRetentionEnvDefault is SEND_LOG_RETENTION_DAYS at panel start; used
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ func TestInboundListShowsOnlyTheAssignedDomains(t *testing.T) {
|
|||||||
if !strings.Contains(body, "a.example.com") || strings.Contains(body, "b.example.com") {
|
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)
|
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) {
|
if strings.Contains(body, globalOnly) {
|
||||||
t.Errorf("the list offers a domain administrator %q", 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)
|
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)
|
t.Errorf("the global role lost the full list or the add form:\n%s", body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||||
@@ -13,16 +12,9 @@ import (
|
|||||||
"github.com/mixeme/selfpost/internal/store"
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
"github.com/mixeme/selfpost/internal/web/auth"
|
"github.com/mixeme/selfpost/internal/web/auth"
|
||||||
"github.com/mixeme/selfpost/internal/web/validate"
|
"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
|
// Inbound is delegated to domain administrators separately from outbound (plan
|
||||||
// § Who sees what), so its handlers come in three strengths:
|
// § 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)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data := h.pageBase(r)
|
flash := ""
|
||||||
data["Title"] = "SelfPost — inbound"
|
|
||||||
data["Active"] = "inbound"
|
|
||||||
data["Domains"] = h.inboundRows(list)
|
|
||||||
data["Error"] = formErr
|
|
||||||
data["FormName"] = formName
|
|
||||||
if r.URL.Query().Get("deleted") != "" {
|
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 {
|
// inboundRows is one row per inbound domain: the verdict of the MX check
|
||||||
rows := make([]inboundRow, len(domains))
|
// (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
|
var wg sync.WaitGroup
|
||||||
for i, d := range domains {
|
for i, d := range domains {
|
||||||
rows[i] = inboundRow{
|
in := view.InDomainInput{
|
||||||
InboundDomain: d,
|
ID: d.ID, Name: d.Name, DNSStatus: string(health.StatusUnknown),
|
||||||
DNS: health.StatusUnknown,
|
Host: d.Host, Port: d.Port, TLSMode: d.TLSMode,
|
||||||
Upstream: inboundUpstream(d),
|
RecipientMode: d.RecipientMode, RecipientCount: d.RecipientCount,
|
||||||
TLSLabel: tlsLabel(d.TLSMode),
|
|
||||||
RcptLabel: rcptLabel(d),
|
|
||||||
}
|
}
|
||||||
|
rows[i] = view.NewInDomainRow(in, canDelete)
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
if h.dns != nil && h.cfg.Hostname != "" {
|
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
|
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
|
// HandleAddInbound validates the name, creates the inbound domain, and
|
||||||
// redirects to its page.
|
// redirects to its page.
|
||||||
func (h *Handlers) HandleAddInbound(w http.ResponseWriter, r *http.Request) {
|
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 != "" {
|
if h.dns != nil && h.cfg.Hostname != "" {
|
||||||
mx = h.dns.InboundMX(d.Name, h.cfg.Hostname, false)
|
mx = h.dns.InboundMX(d.Name, h.cfg.Hostname, false)
|
||||||
}
|
}
|
||||||
data := h.pageBase(r)
|
on, milter, action := h.inboundFilter()
|
||||||
data["Title"] = "SelfPost — " + d.Name
|
page := view.NewInDomain(h.shellMeta(r), view.InDomainInput{
|
||||||
data["Active"] = "inbound"
|
ID: d.ID, Name: d.Name, DNSStatus: string(mx.Status), Host: d.Host, Port: d.Port, TLSMode: d.TLSMode,
|
||||||
data["Domain"] = d
|
RecipientMode: d.RecipientMode, RecipientCount: d.RecipientCount, Addresses: d.Recipients,
|
||||||
data["MX"] = mx
|
}).
|
||||||
data["MXValue"] = "10 " + strings.TrimSuffix(h.cfg.Hostname, ".") + "."
|
WithMX(h.cfg.Hostname, view.DNSCheck{Status: string(mx.Status), Detail: mx.Detail, Found: mx.Records}).
|
||||||
data["Hostname"] = h.cfg.Hostname
|
WithFilter(on, milter, action).
|
||||||
data["TLSLabel"] = tlsLabel(d.TLSMode)
|
WithResult(inboundFlash(r), firstNonEmpty(extra.FormErr, extra.TransportErr, extra.RecipientErr))
|
||||||
data["TLSClass"] = tlsStatusClass(d.TLSMode)
|
h.view.Render(w, status, "in-domain", page)
|
||||||
data["RecipientText"] = strings.Join(d.Recipients, "\n")
|
}
|
||||||
data["Flash"] = inboundFlash(r)
|
|
||||||
data["FormErr"] = extra.FormErr
|
// inboundFilter says whether the instance filters inbound mail, and with what:
|
||||||
data["TransportErr"] = extra.TransportErr
|
// the milter address and what Postfix does when it is down. It mirrors
|
||||||
data["RecipientErr"] = extra.RecipientErr
|
// build/postfix-config.sh, which attaches the filter only when the inbound
|
||||||
h.view.Render(w, status, "inbound_domain", data)
|
// 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 {
|
func inboundFlash(r *http.Request) string {
|
||||||
@@ -330,12 +303,12 @@ func (h *Handlers) HandleInboundDeleteConfirm(w http.ResponseWriter, r *http.Req
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data := h.pageBase(r)
|
listed := 0
|
||||||
data["Title"] = "SelfPost — delete " + d.Name
|
if d.RecipientMode != store.RecipientModeAny {
|
||||||
data["Active"] = "inbound"
|
listed = d.RecipientCount
|
||||||
data["Domain"] = d
|
}
|
||||||
data["Upstream"] = inboundUpstream(d)
|
h.view.Render(w, http.StatusOK, "in-domain-delete",
|
||||||
h.view.Render(w, http.StatusOK, "inbound_delete", data)
|
view.NewInDomainDelete(h.shellMeta(r), d.ID, d.Name, d.Host != "", listed))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handlers) HandleInboundDelete(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleInboundDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -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, `<h1 class="title is-3">lists.example.com</h1>`,
|
||||||
|
`handed to <span class="sp-mono">mx.internal:2525</span> over required TLS · 2 recipients`,
|
||||||
|
`value="10 mail.example.org."`, `name="host" value="mx.internal"`, `value="2525"`,
|
||||||
|
`<option value="encrypt" selected>Required</option>`, "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(), `<div class="notification is-danger is-light">`, `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(), `<div class="notification is-danger is-light">`, "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, `<div class="notification is-success is-light">`, `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 (<code>INBOUND_ANTISPAM_MILTER</code>)`)
|
||||||
|
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 <span class="tag is-success is-light">on</span> · 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 <span class="tag is-light">off</span> · 2 domains`)
|
||||||
|
}
|
||||||
@@ -82,7 +82,7 @@ func TestInboundListAndAdd(t *testing.T) {
|
|||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("list = %d\n%s", rec.Code, rec.Body.String())
|
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")
|
t.Fatal("list missing add form")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,3 +158,72 @@ func TestInboundDelete(t *testing.T) {
|
|||||||
t.Fatal("domain still present")
|
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, `<h1 class="title is-3">Inbound domains</h1>`, `<h2>Add a domain</h2>`, `action="/inbound/domains"`,
|
||||||
|
`<strong>lists.example.com</strong>`, `>mx.internal:2525<`, `required</span>`, `opportunistic</span>`, `>—<`,
|
||||||
|
`0 addresses`, `href="/inbound/domains/`+itoa(d.ID)+`/delete"`, `2 domains`, `>MX</span>`)
|
||||||
|
|
||||||
|
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(), `<div class="notification is-danger is-light">`,
|
||||||
|
`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, `<h1 class="title is-3">Delete lists.example.com</h1>`, `sp-danger-zone`,
|
||||||
|
`stop accepting mail for it on port 25`, `action="/inbound/domains/`+id+`/delete"`,
|
||||||
|
`href="/inbound/domains/`+id+`">Keep it</a>`)
|
||||||
|
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`)
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,10 +48,9 @@ func (h *Handlers) HandleOverview(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, row := range h.inboundRows(list) {
|
for _, row := range h.inboundRows(list, false) {
|
||||||
page.InboundRows = append(page.InboundRows, view.OverviewInbound{
|
page.InboundRows = append(page.InboundRows, view.OverviewInbound{
|
||||||
Name: row.Name, Href: fmt.Sprintf("/inbound/domains/%d", row.ID),
|
Name: row.Name, Href: row.Href, MX: row.DNS, Upstream: row.Upstream,
|
||||||
MX: view.Tag{Status: string(row.DNS), Label: "MX"}, Upstream: row.Upstream,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ var pageFixtures = map[string]func() any{
|
|||||||
"out-app-created": func() any { return outAppCreatedFixture() },
|
"out-app-created": func() any { return outAppCreatedFixture() },
|
||||||
"out-domain-delete": func() any { return outDomainDeleteFixture() },
|
"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-log": func() any { return outLogFixture() },
|
||||||
"out-message": func() any { return outMessageFixture() },
|
"out-message": func() any { return outMessageFixture() },
|
||||||
"dmarc": func() any { return dmarcHubFixture() },
|
"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"},
|
{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
|
# <name> a page of the view engine that is still the old template
|
||||||
backup
|
backup
|
||||||
help
|
help
|
||||||
inbound
|
|
||||||
inbound_delete
|
|
||||||
inbound_domain
|
|
||||||
mail_queue
|
mail_queue
|
||||||
system_log
|
system_log
|
||||||
user_delete
|
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": {"templates/out_app.html"},
|
||||||
"out-app-created": {"templates/out_app_created.html"},
|
"out-app-created": {"templates/out_app_created.html"},
|
||||||
"out-domain-delete": {"templates/out_domain_delete.html"},
|
"out-domain-delete": {"templates/out_domain_delete.html"},
|
||||||
"inbound": {"templates/inbound.html"},
|
"in-domains": {"templates/in_domains.html"},
|
||||||
"inbound_domain": {"templates/inbound_domain.html"},
|
"in-domain": {"templates/in_domain.html"},
|
||||||
"inbound_delete": {"templates/inbound_delete.html"},
|
"in-domain-delete": {"templates/in_domain_delete.html"},
|
||||||
"out-log": {"templates/out_log.html", "templates/out_log_rows.html"},
|
"out-log": {"templates/out_log.html", "templates/out_log_rows.html"},
|
||||||
"out-message": {"templates/out_message.html"},
|
"out-message": {"templates/out_message.html"},
|
||||||
"dmarc": {"templates/dmarc.html"},
|
"dmarc": {"templates/dmarc.html"},
|
||||||
@@ -88,6 +88,10 @@ var kitPages = map[string]bool{
|
|||||||
"out-app-created": true,
|
"out-app-created": true,
|
||||||
"out-domain-delete": true,
|
"out-domain-delete": true,
|
||||||
|
|
||||||
|
"in-domains": true,
|
||||||
|
"in-domain": true,
|
||||||
|
"in-domain-delete": true,
|
||||||
|
|
||||||
"out-log": true,
|
"out-log": true,
|
||||||
"out-message": true,
|
"out-message": true,
|
||||||
"dmarc": true,
|
"dmarc": true,
|
||||||
|
|||||||
@@ -87,6 +87,12 @@ type Config struct {
|
|||||||
RetryPolicy postfix.RetryPolicy
|
RetryPolicy postfix.RetryPolicy
|
||||||
// InboundEnabled mirrors INBOUND_RELAY_ENABLE.
|
// InboundEnabled mirrors INBOUND_RELAY_ENABLE.
|
||||||
InboundEnabled bool
|
InboundEnabled bool
|
||||||
|
// InboundAntispamMilter mirrors INBOUND_ANTISPAM_MILTER (inet:host:port or
|
||||||
|
// unix:/path) and InboundAntispamAction INBOUND_ANTISPAM_MILTER_ACTION
|
||||||
|
// ("accept" or "tempfail", "" when the variable holds anything else): the
|
||||||
|
// panel only reports them.
|
||||||
|
InboundAntispamMilter string
|
||||||
|
InboundAntispamAction string
|
||||||
// DMARCEnabled mirrors DMARC_REPORTS_ENABLE.
|
// DMARCEnabled mirrors DMARC_REPORTS_ENABLE.
|
||||||
DMARCEnabled bool
|
DMARCEnabled bool
|
||||||
// SendLogRetentionEnvDefault is SEND_LOG_RETENTION_DAYS at panel start.
|
// SendLogRetentionEnvDefault is SEND_LOG_RETENTION_DAYS at panel start.
|
||||||
@@ -133,6 +139,8 @@ func New(st *store.Store, domains *domain.Service, apps *app.Service, inboundSvc
|
|||||||
RateLimitWindowSeconds: cfg.RateLimitWindowSeconds,
|
RateLimitWindowSeconds: cfg.RateLimitWindowSeconds,
|
||||||
RetryPolicy: cfg.RetryPolicy,
|
RetryPolicy: cfg.RetryPolicy,
|
||||||
InboundEnabled: cfg.InboundEnabled,
|
InboundEnabled: cfg.InboundEnabled,
|
||||||
|
InboundAntispamMilter: cfg.InboundAntispamMilter,
|
||||||
|
InboundAntispamAction: cfg.InboundAntispamAction,
|
||||||
DMARCEnabled: cfg.DMARCEnabled,
|
DMARCEnabled: cfg.DMARCEnabled,
|
||||||
SendLogRetentionEnvDefault: cfg.SendLogRetentionEnvDefault,
|
SendLogRetentionEnvDefault: cfg.SendLogRetentionEnvDefault,
|
||||||
}, v, dnscheck.New(cfg.DNSResolvers), &health.MachineSampler{}, a)
|
}, v, dnscheck.New(cfg.DNSResolvers), &health.MachineSampler{}, a)
|
||||||
|
|||||||
Reference in New Issue
Block a user