panel-redesign: Help with seventeen topics; every box links to its own
Follows the design commit before it. Written by Sonnet, reviewed by
Opus.
- view/help.go, templates/help.html: the mockup's topics, texts, ids
and menu. The page shows the groups the reader's menu shows:
Overview and Server for the global role, Outbound and Inbound by
reach (Inbound only with the feature on), Account for everyone;
boxes are numbered as shown.
- Box.Help set on the boxes the mockups mark; the inbound MX box has
its link again.
- TestEveryHelpLinkLandsOnATopic also fails on a topic that no page
links to, unless it is listed as menu-only (account) - and on a
stale exception.
Known until the change that follows: the dns, dmarc and account topics
describe the three-choice report address, which the code does not have
yet; Account's DMARC box still links to #dns.
Not verified by running: three sentences rest on Postfix behaviour
(delivery is deferred when a required TLS session cannot be made; a
milter with default_action=tempfail answers 4xx; with "any recipient"
an address the upstream refuses comes back as a bounce).
Evidence
- docs/assets/panel-redesign/evidence/help.png (1440x3600) and
in-domain.png: headless Edge, real templates served locally with
their fixtures - NOT from the container.
- go test -count=1 ./internal/web/... ./cmd/panel: ok (six packages).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,13 +6,13 @@ import (
|
||||
"github.com/mixeme/selfpost/internal/web/view"
|
||||
)
|
||||
|
||||
// HandleHelp renders the Help page. It is for both roles; the topics about the
|
||||
// checks of Overview and Health are left out for a viewer who cannot see them.
|
||||
// HandleHelp renders the Help page. It is for both roles; the sections of pages
|
||||
// a viewer cannot open (Overview and Server, Inbound without a reach or with the
|
||||
// feature off, Outbound without a reach) are left out, as is their menu.
|
||||
func (h *Handlers) HandleHelp(w http.ResponseWriter, r *http.Request) {
|
||||
p, ok := h.principal(r)
|
||||
if !ok {
|
||||
if _, ok := h.principal(r); !ok {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.view.Render(w, http.StatusOK, "help", view.NewHelp(h.shellMeta(r), p.IsGlobal()))
|
||||
h.view.Render(w, http.StatusOK, "help", view.NewHelp(h.shellMeta(r), h.cfg.InboundEnabled))
|
||||
}
|
||||
|
||||
@@ -347,6 +347,49 @@ func TestHelpPageForBothRoles(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Help shows the sections of the pages the reader can open, as the shell does for
|
||||
// the menu: Inbound needs the feature and a reach in it, Outbound a reach in it,
|
||||
// and Overview and Server are the global role's. Account is everyone's.
|
||||
func TestHelpSectionsFollowReachAndFeature(t *testing.T) {
|
||||
h, st := inboundHandlers(t)
|
||||
out, _ := st.AddDomain("out.example.com", "mail")
|
||||
in, _ := st.AddInboundDomain("in.example.com")
|
||||
root := auth.Principal{ID: 1, Username: "admin", Role: auth.RoleGlobal}
|
||||
outOnly := userWith(t, st, "out-only", store.RoleDomain, store.Reach{DomainIDs: []int64{out.ID}})
|
||||
inOnly := userWith(t, st, "in-only", store.RoleDomain, store.Reach{InboundDomainIDs: []int64{in.ID}})
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
who auth.Principal
|
||||
inbound bool
|
||||
want []string
|
||||
lacks []string
|
||||
}{
|
||||
{"global", root, true, []string{"<h2>Overview</h2>", "<h2>Outbound</h2>", "<h2>Inbound</h2>", "<h2>Server</h2>", "<h2>Account</h2>"}, nil},
|
||||
{"global, feature off", root, false, []string{"<h2>Outbound</h2>", "<h2>Server</h2>"}, []string{"<h2>Inbound</h2>", `id="filter"`}},
|
||||
{"outbound only", outOnly, true, []string{"<h2>Outbound</h2>", "<h2>Account</h2>"}, []string{"<h2>Inbound</h2>", "<h2>Overview</h2>", "<h2>Server</h2>"}},
|
||||
{"inbound only", inOnly, true, []string{"<h2>Inbound</h2>", `id="upstream"`, "<h2>Account</h2>"}, []string{"<h2>Outbound</h2>", `id="dns"`, "<h2>Server</h2>"}},
|
||||
{"inbound only, feature off", inOnly, false, []string{"<h2>Account</h2>"}, []string{"<h2>Inbound</h2>", "<h2>Outbound</h2>"}},
|
||||
} {
|
||||
h.cfg.InboundEnabled = tt.inbound
|
||||
rec := send(h.HandleHelp, &tt.who, "GET", "/help", nil, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s: GET /help = %d", tt.name, rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
for _, w := range tt.want {
|
||||
if !strings.Contains(body, w) {
|
||||
t.Errorf("%s: Help is missing %q", tt.name, w)
|
||||
}
|
||||
}
|
||||
for _, g := range tt.lacks {
|
||||
if strings.Contains(body, g) {
|
||||
t.Errorf("%s: Help carries %q", tt.name, g)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Backup
|
||||
|
||||
// A refused pre-flight says what to add to docker-compose.yml as plain text: the
|
||||
|
||||
@@ -172,9 +172,9 @@ func NewSettings(m Meta, retention, level1 string) *Settings {
|
||||
Lead: Rich("What holds for the whole instance, whoever is signed in. Your own name, e-mail and DMARC report address are under ",
|
||||
Link("/account", "Account"), "."),
|
||||
},
|
||||
Retention: Box{No: "01", Title: "Log retention"},
|
||||
Retention: Box{No: "01", Title: "Log retention", Help: topicLink(HelpSettings, "How long rows are kept")},
|
||||
RetentionDays: retention,
|
||||
RateLimits: Box{No: "02", Title: "Sending rate limits", ID: "rate-limits", End: Plain("Read-only")},
|
||||
RateLimits: Box{No: "02", Title: "Sending rate limits", ID: "rate-limits", End: Plain("Read-only"), Help: topicLink(HelpLimits, "Levels 1 and 2")},
|
||||
RateFacts: []Fact{
|
||||
{Label: "Level 1 · per client IP", Value: Plain(level1), Big: true,
|
||||
Note: Rich("Set in ", Code(".env"), "; restart to change. Nothing below may exceed it.")},
|
||||
|
||||
@@ -108,7 +108,7 @@ func NewDMARCHub(m Meta, in IngestInput) *DMARCHub {
|
||||
") only; open a report for the parsed XML."),
|
||||
},
|
||||
IngestOn: true,
|
||||
Ingest: Box{No: "01", Title: "Ingest", End: Rich(TagOf(status, label))},
|
||||
Ingest: Box{No: "01", Title: "Ingest", End: Rich(TagOf(status, label)), Help: topicLink(HelpDMARC, "What the reports are and where they go")},
|
||||
IngestFacts: []Fact{
|
||||
last,
|
||||
{Label: "This week", Value: Plain(FormatCount(int64(in.KeptThisWeek)) + " kept · " + plural(int64(in.ParseFailures), "parse failure", "parse failures"))},
|
||||
@@ -126,6 +126,7 @@ func NewDMARCHub(m Meta, in IngestInput) *DMARCHub {
|
||||
func (p *DMARCHub) WithoutIngest() *DMARCHub {
|
||||
p.IngestOn = false
|
||||
p.Recent.No = "01"
|
||||
p.Recent.Help = p.Ingest.Help
|
||||
return p
|
||||
}
|
||||
|
||||
@@ -226,7 +227,7 @@ func NewDMARCDomain(m Meta, id int64, name string, hub bool, pass, fail, days in
|
||||
withCode(hint)...),
|
||||
Actions: []Action{{Label: "Domain page", Icon: "ti-world-upload", Href: DomainHref(id)}},
|
||||
},
|
||||
Sources: Box{No: "01", Title: fmt.Sprintf("Sources · %d days", days)},
|
||||
Sources: Box{No: "01", Title: fmt.Sprintf("Sources · %d days", days), Help: topicLink(HelpDMARC, "How to read pass, fail and sources")},
|
||||
SourcesEmpty: EmptyState{Icon: "ti-world-upload", Text: Plain("No mail from any source in this window.")},
|
||||
Reports: Box{No: "02", Title: "Reports"},
|
||||
ReportsEmpty: EmptyState{Icon: "ti-report-analytics", Text: Plain("No reports for this domain yet.")},
|
||||
@@ -379,7 +380,7 @@ func NewDMARCReport(m Meta, in ReportInput) *DMARCReport {
|
||||
Compact: true,
|
||||
Route: route,
|
||||
},
|
||||
Report: Box{No: "01", Title: "Report"},
|
||||
Report: Box{No: "01", Title: "Report", Help: topicLink(HelpDMARC, "What an aggregate report is")},
|
||||
ReportFacts: report,
|
||||
Policy: Box{No: "02", Title: "Published policy"},
|
||||
PolicyFacts: policy,
|
||||
|
||||
@@ -99,11 +99,11 @@ func NewHealth(m Meta, flash string) *Health {
|
||||
Lead: Plain("Everything behind the cards on Overview. Refreshes automatically."),
|
||||
Actions: []Action{{Label: "Re-check DNS", Icon: "ti-refresh", Post: "/server/health/recheck"}},
|
||||
},
|
||||
Machine: Box{No: "01", Title: "Machine", ID: "machine"},
|
||||
Machine: Box{No: "01", Title: "Machine", ID: "machine", Help: topicLink(HelpChecks, "What these readings mean")},
|
||||
Processes: Box{No: "02", Title: "Processes", ID: "processes"},
|
||||
Certificate: Box{No: "03", Title: "TLS certificate", ID: "certificate"},
|
||||
Certificate: Box{No: "03", Title: "TLS certificate", ID: "certificate", Help: topicLink(HelpCertificate, "When it warns and what to do")},
|
||||
Sockets: Box{No: "04", Title: "Milter sockets", ID: "sockets"},
|
||||
Hostname: Box{No: "05", Title: "Hostname and reverse DNS", ID: "hostname"},
|
||||
Hostname: Box{No: "05", Title: "Hostname and reverse DNS", ID: "hostname", Help: topicLink(HelpRDNS, "Forward and reverse DNS")},
|
||||
Configuration: Box{No: "06", Title: "Configuration", ID: "configuration",
|
||||
End: Plain("Safe to run at any time")},
|
||||
Reload: Action{Label: "Reload configuration", Icon: "ti-reload", Post: "/server/health/reload"},
|
||||
|
||||
+183
-65
@@ -1,99 +1,217 @@
|
||||
package view
|
||||
|
||||
// The data of the Help page (templates/help.html, mockup help.html): what each
|
||||
// screen checks and what to do when it is not green, as topics that stack inside
|
||||
// two boxes. The head of a numbered box on another page links to its topic with
|
||||
// the anchor of that topic's ID (Box.Help); nothing is shown in a drawer.
|
||||
// screen checks or does and what to do when it is not green, as topics that
|
||||
// stack inside one box per section of the panel. The head of a box on another
|
||||
// page links to its topic with the anchor of that topic's ID (Box.Help); nothing
|
||||
// is shown in a drawer.
|
||||
//
|
||||
// The topics are plain data written here once. A topic is only worded for
|
||||
// something this build has: Help explains the checks and records the panel
|
||||
// shows, not the features of a later release.
|
||||
// The topics are plain data written here once, one paragraph each. A topic is
|
||||
// only worded for something this build has: Help explains the checks, records
|
||||
// and settings the panel shows, not the features of a later release. Text goes
|
||||
// through Plain, Rich and Code, never raw HTML.
|
||||
|
||||
// Help is the data of the Help page. The Overview box explains the checks of
|
||||
// Overview and Health, which only the global role sees, so it is absent for a
|
||||
// domain administrator along with its entries in the menu.
|
||||
import "fmt"
|
||||
|
||||
// Help is the data of the Help page: the menu, and one group of topics per
|
||||
// section of the panel the viewer can open. A domain administrator is not shown
|
||||
// Overview and Server (those pages are 404 for them), nor Inbound without an
|
||||
// inbound reach or with the feature off, nor Outbound without an outbound reach;
|
||||
// the menu lists exactly the topics that are shown.
|
||||
type Help struct {
|
||||
Meta
|
||||
Head Head
|
||||
|
||||
Menu SideMenu
|
||||
Menu SideMenu
|
||||
Groups []HelpGroup
|
||||
}
|
||||
|
||||
Overview Box
|
||||
OverviewTopics []HelpTopic
|
||||
// ShowOverview is whether the Overview box is present.
|
||||
ShowOverview bool
|
||||
|
||||
Outbound Box
|
||||
OutboundTopics []HelpTopic
|
||||
// HelpGroup is one box of the Help page: the section of the panel it explains,
|
||||
// its topics, and the menu entries that jump to them.
|
||||
type HelpGroup struct {
|
||||
Box Box
|
||||
Topics []HelpTopic
|
||||
}
|
||||
|
||||
// Anchors of the Help page. A link to one of these is a Box.Help of another
|
||||
// page; the page must carry exactly the topics its links point at.
|
||||
const (
|
||||
// Overview
|
||||
HelpChecks = "checks"
|
||||
HelpCertificate = "certificate"
|
||||
HelpRDNS = "rdns"
|
||||
HelpDNS = "dns"
|
||||
HelpApps = "apps"
|
||||
HelpLimits = "limits"
|
||||
// Outbound
|
||||
HelpDNS = "dns"
|
||||
HelpApps = "apps"
|
||||
HelpLimits = "limits"
|
||||
HelpLog = "log"
|
||||
HelpQueue = "queue"
|
||||
HelpDMARC = "dmarc"
|
||||
// Inbound
|
||||
HelpInbound = "inbound"
|
||||
HelpUpstream = "upstream"
|
||||
HelpRecipients = "recipients"
|
||||
HelpFilter = "filter"
|
||||
// Server
|
||||
HelpBackup = "backup"
|
||||
HelpUsers = "users"
|
||||
HelpSettings = "settings"
|
||||
// Account
|
||||
HelpAccount = "account"
|
||||
)
|
||||
|
||||
// NewHelp builds the page for a viewer. global is the role that sees Overview
|
||||
// and Server, and so the topics about their checks.
|
||||
func NewHelp(m Meta, global bool) *Help {
|
||||
// topicLink is a box's link to a topic of the Help page.
|
||||
func topicLink(topic, title string) *HelpLink {
|
||||
return &HelpLink{Href: "/help#" + topic, Title: title}
|
||||
}
|
||||
|
||||
// reachesOutbound is whether the viewer has anything in Outbound: the global
|
||||
// role, or a domain administrator with an outbound domain.
|
||||
func (m Meta) reachesOutbound() bool { return m.IsGlobal || m.HasOutbound }
|
||||
|
||||
// NewHelp builds the page for a viewer. inbound is whether the inbound feature is
|
||||
// on; with the viewer's own reach it decides, as the shell does for the menu,
|
||||
// whether Inbound is shown.
|
||||
func NewHelp(m Meta, inbound bool) *Help {
|
||||
m.Title, m.Section, m.Page = "Help", "user", "help"
|
||||
p := &Help{
|
||||
Meta: m,
|
||||
Head: Head{
|
||||
Kicker: "Reference",
|
||||
Title: "Help",
|
||||
Lead: Plain("What each screen checks and what to do when it is not green. The ? in a box's head opens its topic here."),
|
||||
},
|
||||
ShowOverview: global,
|
||||
Overview: Box{No: "01", Title: "Overview"},
|
||||
OverviewTopics: []HelpTopic{
|
||||
{ID: HelpChecks, Title: "Health checks", Body: []Text{
|
||||
Rich("Six checks, and the worst of them is the postmark. ", TagOf("ok", ""), " needs nothing; ",
|
||||
TagOf("warn", ""), " still delivers mail but will stop doing so if ignored; ",
|
||||
TagOf("fail", ""), " means mail is being lost or refused now. ", Link("/server/health", "Health"),
|
||||
" shows the readings behind them: CPU and memory are the container's own, not the host's spare capacity, and network is a short window, not a daily total."),
|
||||
}},
|
||||
{ID: HelpCertificate, Title: "TLS certificate", Body: []Text{
|
||||
Plain("The certificate Postfix presents on 465 and 587. The reverse proxy or the image mounts it, the panel does not issue it. It warns 14 days before expiry and fails once expired, or when the file is missing or unreadable — clients that verify certificates then refuse to connect."),
|
||||
}},
|
||||
{ID: HelpRDNS, Title: "Hostname and reverse DNS", Body: []Text{
|
||||
Rich("The hostname (", Code("SELFPOST_HOSTNAME"), ") must resolve to this server's address, and that address must resolve back to the hostname. Large receivers reject or spam-folder mail from hosts where the two disagree. The PTR record is set at your hosting provider, not in your DNS zone."),
|
||||
}},
|
||||
},
|
||||
Outbound: Box{No: "02", Title: "Outbound"},
|
||||
OutboundTopics: []HelpTopic{
|
||||
{ID: HelpDNS, Title: "DNS records", Body: []Text{
|
||||
Rich("DKIM proves the message was signed by this relay, SPF lists the addresses allowed to send for the domain, DMARC tells receivers what to do when both fail and where to report. Publish all three exactly as shown; merge SPF into an existing record rather than adding a second one. A domain's status is the worst of its three records, and results are cached for a few minutes, so use Re-check DNS after publishing. The SPF check is shallow: it looks for this server's address in the record itself and does not follow ",
|
||||
Code("include:"), " or ", Code("redirect="), ", so a record that relies on them is reported as a warning, not a failure. ",
|
||||
Code("p=none"), " does not affect delivery; tighten to ", Code("quarantine"), ", then ", Code("reject"),
|
||||
", once the reports look clean. A domain's report address is set in its settings; the default it follows belongs to a user and is set under ",
|
||||
Link("/account#dmarc", "Account"), "."),
|
||||
}},
|
||||
{ID: HelpApps, Title: "Applications", Body: []Text{
|
||||
Plain("An application is one SASL login, unique across all domains. It may send as any address of its domain or only as listed addresses, and, with a list of client IPs set, only from those addresses. The password is shown once at creation or regeneration and is not stored in readable form."),
|
||||
}},
|
||||
{ID: HelpLimits, Title: "Rate limits", Body: []Text{
|
||||
Rich("Level 1 is a per-IP ceiling set in ", Code(".env"), ". Level 2 is optional, per domain and per application, entered by hand or computed from the last 30 days of sending, and can never exceed level 1. An application's own limit replaces its domain's."),
|
||||
}},
|
||||
Lead: Plain("What each screen checks or does, and what to do when it is not green. The ? in a box's head opens its topic here."),
|
||||
},
|
||||
}
|
||||
p.Menu = SideMenu{}
|
||||
if global {
|
||||
p.Menu.Groups = append(p.Menu.Groups, MenuGroup{Label: "Overview", Items: []MenuItem{
|
||||
add := func(title string, topics []HelpTopic, menu []MenuItem) {
|
||||
p.Groups = append(p.Groups, HelpGroup{Box: Box{Title: title}, Topics: topics})
|
||||
p.Menu.Groups = append(p.Menu.Groups, MenuGroup{Label: title, Items: menu})
|
||||
}
|
||||
if m.IsGlobal {
|
||||
add("Overview", overviewTopics, []MenuItem{
|
||||
{Label: "Health checks", Icon: "ti-activity-heartbeat", Href: "#" + HelpChecks},
|
||||
{Label: "TLS certificate", Icon: "ti-certificate", Href: "#" + HelpCertificate},
|
||||
{Label: "Reverse DNS", Icon: "ti-arrows-exchange", Href: "#" + HelpRDNS},
|
||||
}})
|
||||
})
|
||||
}
|
||||
if m.reachesOutbound() {
|
||||
add("Outbound", outboundTopics, []MenuItem{
|
||||
{Label: "DNS records", Icon: "ti-world-check", Href: "#" + HelpDNS},
|
||||
{Label: "Applications", Icon: "ti-apps", Href: "#" + HelpApps},
|
||||
{Label: "Rate limits", Icon: "ti-gauge", Href: "#" + HelpLimits},
|
||||
{Label: "Outbound log", Icon: "ti-list-details", Href: "#" + HelpLog},
|
||||
{Label: "Queue and retries", Icon: "ti-stack-2", Href: "#" + HelpQueue},
|
||||
{Label: "DMARC reports", Icon: "ti-report-analytics", Href: "#" + HelpDMARC},
|
||||
})
|
||||
}
|
||||
if inbound && (m.IsGlobal || m.HasInbound) {
|
||||
add("Inbound", inboundTopics, []MenuItem{
|
||||
{Label: "Backup-MX and forwarding", Icon: "ti-inbox", Href: "#" + HelpInbound},
|
||||
{Label: "Upstream", Icon: "ti-arrow-forward-up", Href: "#" + HelpUpstream},
|
||||
{Label: "Valid recipients", Icon: "ti-address-book", Href: "#" + HelpRecipients},
|
||||
{Label: "Spam filter", Icon: "ti-shield-check", Href: "#" + HelpFilter},
|
||||
})
|
||||
}
|
||||
if m.IsGlobal {
|
||||
add("Server", serverTopics, []MenuItem{
|
||||
{Label: "Backup and moving a domain", Icon: "ti-archive", Href: "#" + HelpBackup},
|
||||
{Label: "Users and roles", Icon: "ti-users", Href: "#" + HelpUsers},
|
||||
{Label: "Settings", Icon: "ti-settings", Href: "#" + HelpSettings},
|
||||
})
|
||||
}
|
||||
add("Account", accountTopics, []MenuItem{
|
||||
{Label: "Your account", Icon: "ti-user-circle", Href: "#" + HelpAccount},
|
||||
})
|
||||
for i := range p.Groups {
|
||||
p.Groups[i].Box.No = fmt.Sprintf("%02d", i+1)
|
||||
}
|
||||
p.Menu.Groups = append(p.Menu.Groups, MenuGroup{Label: "Outbound", Items: []MenuItem{
|
||||
{Label: "DNS records", Icon: "ti-world-check", Href: "#" + HelpDNS},
|
||||
{Label: "Applications", Icon: "ti-apps", Href: "#" + HelpApps},
|
||||
{Label: "Rate limits", Icon: "ti-gauge", Href: "#" + HelpLimits},
|
||||
}})
|
||||
return p
|
||||
}
|
||||
|
||||
var overviewTopics = []HelpTopic{
|
||||
{ID: HelpChecks, Title: "Health checks", Body: []Text{
|
||||
Rich("Six checks, and the worst of them is the postmark. ", TagOf("ok", ""), " needs nothing; ",
|
||||
TagOf("warn", ""), " still delivers mail but will stop doing so if ignored; ",
|
||||
TagOf("fail", ""), " means mail is being lost or refused now. ", Link("/server/health", "Health"),
|
||||
" shows the readings behind them: CPU and memory are the container's own, not the host's spare capacity, and network is a short window, not a daily total."),
|
||||
}},
|
||||
{ID: HelpCertificate, Title: "TLS certificate", Body: []Text{
|
||||
Plain("The certificate Postfix presents on 465 and 587. The reverse proxy or the image mounts it, the panel does not issue it. It warns 14 days before expiry and fails once expired, or when the file is missing or unreadable — clients that verify certificates then refuse to connect."),
|
||||
}},
|
||||
{ID: HelpRDNS, Title: "Hostname and reverse DNS", Body: []Text{
|
||||
Rich("The hostname (", Code("SELFPOST_HOSTNAME"), ") must resolve to this server's address, and that address must resolve back to the hostname. Large receivers reject or spam-folder mail from hosts where the two disagree. The PTR record is set at your hosting provider, not in your DNS zone."),
|
||||
}},
|
||||
}
|
||||
|
||||
var outboundTopics = []HelpTopic{
|
||||
{ID: HelpDNS, Title: "DNS records", Body: []Text{
|
||||
Rich("DKIM proves the message was signed by this relay, SPF lists the addresses allowed to send for the domain, DMARC tells receivers what to do when both fail and where to report. Publish all three exactly as shown; merge SPF into an existing record rather than adding a second one. A domain's status is the worst of its three records, and results are cached for a few minutes, so use Re-check DNS after publishing. The SPF check is shallow: it looks for this server's address in the record itself and does not follow ",
|
||||
Code("include:"), " or ", Code("redirect="), ", so a record that relies on them is reported as a warning, not a failure. ",
|
||||
Code("p=none"), " does not affect delivery; tighten to ", Code("quarantine"), ", then ", Code("reject"),
|
||||
", once the reports look clean. A domain's report address is set in its Domain settings."),
|
||||
}},
|
||||
{ID: HelpApps, Title: "Applications", Body: []Text{
|
||||
Plain("An application is one SASL login, unique across all domains. It may send as any address of its domain or only as listed addresses, and, with a list of client IPs set, only from those addresses. The password is shown once at creation or regeneration and is not stored in readable form."),
|
||||
}},
|
||||
{ID: HelpLimits, Title: "Rate limits", Body: []Text{
|
||||
Rich("Level 1 is a per-IP ceiling set in ", Code(".env"), ". Level 2 is optional, per domain and per application, entered by hand or computed from the last 30 days of sending, and can never exceed level 1. An application's own limit replaces its domain's."),
|
||||
}},
|
||||
{ID: HelpLog, Title: "Outbound log", Body: []Text{
|
||||
Rich("One row per message and recipient, with the status Postfix last reported: queued (accepted, nothing reported yet), sent (the receiving server took it; what the mailbox does next is not reported back), deferred (not taken yet, Postfix tries again), bounced (failed for good, or the queue lifetime ran out) and rejected (refused before queueing under a level-2 rate limit, so it never reached Postfix). A message's page lists these steps and the lines Postfix wrote about it in ",
|
||||
Code("mail.log"), ", found by queue id; that file is rotated daily and about two weeks are kept, so an old message may show none. Rows are deleted after the retention set in Server › Settings, 90 days by default. A domain administrator sees only the rows of their own domains."),
|
||||
}},
|
||||
{ID: HelpQueue, Title: "Queue and retries", Body: []Text{
|
||||
Rich("The page lists, as ", Code("postqueue -p"), " prints it, the messages Postfix has accepted and not yet delivered: mail being sent right now and mail deferred for a later attempt. Only the global role can open it, and it only shows; nothing here deletes or forces a message. A deferred message is tried again after the first retry delay, then at gaps that double up to a cap, until the receiving server takes it or the queue lifetime runs out and it bounces; there is no attempt counter, and the box on the page shows this server's values. A queue that keeps growing means a receiver is refusing or not answering: open the message from the Outbound log to read the reason Postfix logged."),
|
||||
}},
|
||||
{ID: HelpDMARC, Title: "DMARC reports", Body: []Text{
|
||||
Rich("An aggregate report is the summary a receiving provider sends, usually daily, to the ", Code("rua="),
|
||||
" address of your DMARC record: which servers sent mail as your domain and whether SPF or DKIM passed for it. A domain's report address is one of three choices, set in its Domain settings: no reports (the default for a new domain), the SelfPost hosted address (an address on this server whose reports are parsed and shown under DMARC reports; offered only while hosted reports are enabled on the server), or an address typed for the domain (an ordinary mailbox that receives the raw XML; a button fills in your own profile e-mail). When the typed address is on another domain than the one sending, that domain must publish a report-authorization record, or providers will not send; the domain's page shows it with the other DNS records and checks it. In the tables pass means SPF or DKIM passed and aligned, fail means neither did, and your own server shows as ",
|
||||
Em("this relay"), ". A fail from any other source is someone else sending as your domain — a forwarder, an application you forgot, or abuse — so do not tighten ",
|
||||
Code("p="), " until that sender is fixed or removed. A domain administrator reads the reports of their own domains."),
|
||||
}},
|
||||
}
|
||||
|
||||
var inboundTopics = []HelpTopic{
|
||||
{ID: HelpInbound, Title: "Backup-MX and forwarding", Body: []Text{
|
||||
Rich("An optional backup-MX or forwarder, switched on in the server's ", Code(".env"), "; the Inbound menu exists only then. Postfix accepts mail on port 25 for the listed domains only and hands each message on to that domain's upstream; there are no mailboxes. For each domain publish the MX record its page shows, and keep a primary MX the domain already has if this is a backup. The MX tag is green when at least one MX of the domain points at this server, and a domain accepts no mail until an upstream is saved on its page. Adding and deleting domains is for the global role."),
|
||||
}},
|
||||
{ID: HelpUpstream, Title: "Upstream", Body: []Text{
|
||||
Plain("The upstream is the server that actually holds the mailboxes. Give its host and port — used exactly as written, with no MX lookup — and choose the TLS for the hop to it: Opportunistic uses TLS when the upstream offers it and otherwise sends in the clear, Required holds the mail until a TLS session is possible, Off never uses TLS. While the upstream does not answer, Postfix keeps the mail and retries as for any other mail."),
|
||||
}},
|
||||
{ID: HelpRecipients, Title: "Valid recipients", Body: []Text{
|
||||
Plain("Choose whom this domain accepts. Listed addresses only: the relay accepts exactly the addresses you list, one per line or comma-separated, and refuses any other while the sender is still connected, so it never has to send a bounce for them. Any recipient: everything at the domain is accepted and passed on, and an address the upstream then rejects comes back as a bounce from this server to a sender who is often forged — backscatter. Prefer a list, and keep it in step with the mailboxes upstream."),
|
||||
}},
|
||||
{ID: HelpFilter, Title: "Spam filter", Body: []Text{
|
||||
Rich("The spam filter belongs to the server, not to a domain. The server's administrator attaches it in the server's ", Code(".env"),
|
||||
", and from then on every inbound domain is screened by it before mail is handed on; the box shows on or off and has nothing to switch. Off means mail goes to the upstream unfiltered. While the filter is down, mail is either accepted unfiltered (the default) or deferred, so the sending server tries again later, as the server's setting says; the box states which."),
|
||||
}},
|
||||
}
|
||||
|
||||
var serverTopics = []HelpTopic{
|
||||
{ID: HelpBackup, Title: "Backup and moving a domain", Body: []Text{
|
||||
Rich("Full backup downloads one archive of the whole instance: ", Code("data/"), " (the database, DKIM keys, application credentials and the queue), ",
|
||||
Code("docker-compose.yml"), ", ", Code(".env"), " and ", Code("certs/"),
|
||||
". It holds secrets, so leave Encrypt with a password ticked: the file is sealed as ", Code(".spbk"),
|
||||
", the password is not stored, and without it nothing can be recovered. To restore, unpack it into an empty project directory (decrypt a ",
|
||||
Code(".spbk"), " first with ", Code("selfpost-backup -decrypt"),
|
||||
") and start the same SelfPost version that made it; the panel refuses data from another version. The reverse-proxy configuration and ",
|
||||
Code("mail.log"), " are not included. One domain moves on its own: Export domain on its settings page writes a ", Code(".json"),
|
||||
" (or encrypted ", Code(".spde"), ") with its DKIM key and its applications' passwords, and Import a domain here reads it into another instance, so the published DNS needs no change."),
|
||||
}},
|
||||
{ID: HelpUsers, Title: "Users and roles", Body: []Text{
|
||||
Rich("The two roles are a reach, not a rank: every user is an administrator and signs in with a username and password. Global reaches the whole server — Overview, Server, the queue and every domain — and is the only role that adds or deletes domains and users. Domain reaches only the domains assigned to it, outbound and inbound separately (the same name on both lists is two assignments); ",
|
||||
Em("All"), " includes domains added later, and a domain user needs at least one domain or ", Em("All"),
|
||||
" on either list. The only global user cannot be demoted or deleted, and you cannot delete the account you are signed in with. A new password set here signs that user out of their other sessions."),
|
||||
}},
|
||||
{ID: HelpSettings, Title: "Settings", Body: []Text{
|
||||
Rich("Settings holds what is true of the whole instance. Log retention is how many days the Outbound log keeps its rows, from 7 to 365 and 90 by default; a sweep every six hours deletes older ones, and it is the main driver of how fast ",
|
||||
Code("/data"), " grows. It does not change the rotation of ", Code("mail.log"),
|
||||
", which is daily with about two weeks kept. The sending rate limit shown under it is level 1, the per-client-IP ceiling set in ",
|
||||
Code(".env"), ": changing it needs a restart, and no level-2 limit can exceed it."),
|
||||
}},
|
||||
}
|
||||
|
||||
var accountTopics = []HelpTopic{
|
||||
{ID: HelpAccount, Title: "Your account", Body: []Text{
|
||||
Plain("Account is your own, whatever your role: your username and e-mail, and your password. Changing the password signs out every other session of this account and keeps the one you are using; applications have their own logins and are not affected."),
|
||||
}},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
package view
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The tests of the Help page that depend on who is reading it. The one that
|
||||
// every link of every page lands on a topic is TestEveryHelpLinkLandsOnATopic
|
||||
// (server_test.go).
|
||||
|
||||
// helpViewers are the readers of the Help page and the sections each is shown.
|
||||
var helpViewers = []struct {
|
||||
name string
|
||||
meta Meta
|
||||
inbound bool
|
||||
groups []string
|
||||
}{
|
||||
{"global", Meta{User: "admin", IsGlobal: true}, true, []string{"Overview", "Outbound", "Inbound", "Server", "Account"}},
|
||||
{"global, inbound feature off", Meta{User: "admin", IsGlobal: true}, false, []string{"Overview", "Outbound", "Server", "Account"}},
|
||||
{"domain, outbound only", Meta{User: "ops", HasOutbound: true}, true, []string{"Outbound", "Account"}},
|
||||
{"domain, inbound only", Meta{User: "ops", HasInbound: true}, true, []string{"Inbound", "Account"}},
|
||||
{"domain, inbound only, feature off", Meta{User: "ops", HasInbound: true}, false, []string{"Account"}},
|
||||
{"domain, both", Meta{User: "ops", HasOutbound: true, HasInbound: true}, true, []string{"Outbound", "Inbound", "Account"}},
|
||||
{"domain, both, feature off", Meta{User: "ops", HasOutbound: true, HasInbound: true}, false, []string{"Outbound", "Account"}},
|
||||
}
|
||||
|
||||
// Each reader is shown the sections of the pages they can open and no other,
|
||||
// numbered one after the other, and the menu lists the same sections.
|
||||
func TestHelpShowsEachRoleOnlyItsSections(t *testing.T) {
|
||||
groupTitle := regexp.MustCompile(`<span class="sp-no">(\d+)</span><h2>([^<]+)</h2>`)
|
||||
menuLabel := regexp.MustCompile(`<p class="menu-label">([^<]+)</p>`)
|
||||
for _, v := range helpViewers {
|
||||
out := renderSignedIn(t, "help", NewHelp(v.meta, v.inbound))
|
||||
var got, numbers, menu []string
|
||||
for _, m := range groupTitle.FindAllStringSubmatch(out, -1) {
|
||||
numbers, got = append(numbers, m[1]), append(got, m[2])
|
||||
}
|
||||
for _, m := range menuLabel.FindAllStringSubmatch(out, -1) {
|
||||
menu = append(menu, m[1])
|
||||
}
|
||||
if strings.Join(got, ",") != strings.Join(v.groups, ",") {
|
||||
t.Errorf("%s: sections %v, want %v", v.name, got, v.groups)
|
||||
}
|
||||
if strings.Join(menu, ",") != strings.Join(v.groups, ",") {
|
||||
t.Errorf("%s: the menu lists %v, the page shows %v", v.name, menu, v.groups)
|
||||
}
|
||||
for i, n := range numbers {
|
||||
if want := fmt.Sprintf("%02d", i+1); n != want {
|
||||
t.Errorf("%s: section %d is numbered %s, want %s", v.name, i+1, n, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The menu of the Help page jumps to topics that exist, in the order they are
|
||||
// shown, and every topic shown is in it; no id is used twice on the page.
|
||||
func TestHelpMenuAndTopicsAgree(t *testing.T) {
|
||||
menuLink := regexp.MustCompile(`<a (?:class="is-active" )?href="#([a-z-]+)">`)
|
||||
for _, v := range helpViewers {
|
||||
out := renderSignedIn(t, "help", NewHelp(v.meta, v.inbound))
|
||||
topics := helpTopic.FindAllStringSubmatch(out, -1)
|
||||
menu := menuLink.FindAllStringSubmatch(out, -1)
|
||||
if len(topics) != len(menu) || len(topics) == 0 {
|
||||
t.Fatalf("%s: %d topics, %d menu entries", v.name, len(topics), len(menu))
|
||||
}
|
||||
for i := range topics {
|
||||
if topics[i][1] != menu[i][1] {
|
||||
t.Errorf("%s: topic %d is %q, its menu entry points at %q", v.name, i, topics[i][1], menu[i][1])
|
||||
}
|
||||
}
|
||||
unique(t, "Help ("+v.name+")", ids(out))
|
||||
}
|
||||
}
|
||||
|
||||
// The topics about Overview and Health, and about the Server pages, are for the
|
||||
// role that sees those pages; a domain administrator is not shown them, nor a
|
||||
// link to them.
|
||||
func TestHelpLeavesOutWhatADomainAdministratorCannotOpen(t *testing.T) {
|
||||
out := renderSignedIn(t, "help", NewHelp(Meta{User: "ops", HasOutbound: true, HasInbound: true}, true))
|
||||
pageHas(t, "Help", out, `id="dns"`, `id="apps"`, `id="limits"`, `id="log"`, `id="queue"`, `id="dmarc"`,
|
||||
`id="inbound"`, `id="upstream"`, `id="recipients"`, `id="filter"`, `id="account"`,
|
||||
`<h2>Outbound</h2>`, `<h2>Inbound</h2>`, `<h2>Account</h2>`)
|
||||
pageLacks(t, "Help", out, `id="checks"`, `id="certificate"`, `id="rdns"`, `id="backup"`, `id="users"`, `id="settings"`,
|
||||
`<h2>Overview</h2>`, `<h2>Server</h2>`, `href="/server/health"`)
|
||||
}
|
||||
|
||||
// Help text is data, not markup: a topic is one paragraph of Plain, Rich and
|
||||
// Code runs with no raw HTML in it, and the page renders each topic as a
|
||||
// heading and exactly one paragraph.
|
||||
func TestHelpTopicsAreOneParagraphOfPlainText(t *testing.T) {
|
||||
p := NewHelp(admin(), true)
|
||||
seen := map[string]bool{}
|
||||
for _, g := range p.Groups {
|
||||
for _, topic := range g.Topics {
|
||||
if seen[topic.ID] {
|
||||
t.Errorf("topic id %q is used twice", topic.ID)
|
||||
}
|
||||
seen[topic.ID] = true
|
||||
if len(topic.Body) != 1 {
|
||||
t.Errorf("topic %q has %d paragraphs, want one: paragraph margins are reset, so several would run together", topic.ID, len(topic.Body))
|
||||
}
|
||||
texts := []string{topic.Title}
|
||||
for _, text := range topic.Body {
|
||||
for _, in := range text {
|
||||
texts = append(texts, in.Text)
|
||||
}
|
||||
}
|
||||
for _, s := range texts {
|
||||
if strings.ContainsAny(s, "<>") || strings.Contains(s, "&") || strings.Contains(s, "<") {
|
||||
t.Errorf("topic %q holds markup: %q", topic.ID, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out := renderSignedIn(t, "help", p)
|
||||
blocks := regexp.MustCompile(`(?s)<div class="sp-help-topic" id="([a-z-]+)">(.*?)</div>`).FindAllStringSubmatch(out, -1)
|
||||
if len(blocks) != len(seen) {
|
||||
t.Errorf("%d topics rendered, %d in the data", len(blocks), len(seen))
|
||||
}
|
||||
for _, m := range blocks {
|
||||
if n, h := strings.Count(m[2], "<p>"), strings.Count(m[2], "<h3>"); n != 1 || h != 1 {
|
||||
t.Errorf("topic %q renders %d paragraphs and %d headings, want one of each", m[1], n, h)
|
||||
}
|
||||
}
|
||||
pageLacks(t, "Help", out, `&lt;`, `<a `)
|
||||
}
|
||||
|
||||
// A link leads only to a topic its reader is shown. Boxes on pages that more
|
||||
// than one role opens have their topic in a section only some are shown, so what
|
||||
// they link to depends on the viewer.
|
||||
func TestHelpLinksStayInsideWhatTheirViewerIsShown(t *testing.T) {
|
||||
for _, v := range helpViewers {
|
||||
shown := map[string]bool{}
|
||||
for _, m := range helpTopic.FindAllStringSubmatch(renderSignedIn(t, "help", NewHelp(v.meta, v.inbound)), -1) {
|
||||
shown[m[1]] = true
|
||||
}
|
||||
landsOn := func(where string, l *HelpLink) {
|
||||
t.Helper()
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
if anchor := strings.TrimPrefix(l.Href, "/help#"); !shown[anchor] {
|
||||
t.Errorf("%s, %s: a box links to /help#%s, a topic that reader is not shown", v.name, where, anchor)
|
||||
}
|
||||
}
|
||||
|
||||
if !v.meta.reachesOutbound() {
|
||||
continue
|
||||
}
|
||||
settings := NewOutDomainSettings(v.meta, 1, "example.org", 2, false)
|
||||
landsOn("Domain settings", settings.Reports.Help)
|
||||
landsOn("Domain settings", settings.Limit.Help)
|
||||
landsOn("Domain settings", settings.Export.Help)
|
||||
if has := settings.Export.Help != nil; has != v.meta.IsGlobal {
|
||||
t.Errorf("%s: the Export box has a Help link = %v, want %v (its topic is in Server)", v.name, has, v.meta.IsGlobal)
|
||||
}
|
||||
hub := NewDMARCHub(v.meta, IngestInput{}).WithoutIngest()
|
||||
if hub.Recent.Help == nil || hub.Recent.No != "01" {
|
||||
t.Errorf("%s: without the Ingest box the first box of the DMARC hub is %+v, want it numbered 01 with the Help link", v.name, hub.Recent)
|
||||
}
|
||||
landsOn("DMARC hub", hub.Recent.Help)
|
||||
}
|
||||
}
|
||||
@@ -137,7 +137,7 @@ func NewInDomains(m Meta, canManage bool) *InDomains {
|
||||
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"},
|
||||
List: Box{No: "01", Title: "Domains", Help: topicLink(HelpInbound, "Backup-MX and forwarding")},
|
||||
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.")},
|
||||
}
|
||||
@@ -295,9 +295,9 @@ func NewInDomain(m Meta, in InDomainInput) *InDomain {
|
||||
},
|
||||
id: in.ID, name: in.Name, dnsStatus: in.DNSStatus, recipientCount: in.RecipientCount, recipientMode: in.RecipientMode,
|
||||
|
||||
DNS: Box{No: "01", Title: "MX record", ID: "dns"},
|
||||
DNS: Box{No: "01", Title: "MX record", ID: "dns", Help: topicLink(HelpInbound, "Backup-MX and forwarding")},
|
||||
|
||||
Upstream: Box{No: "02", Title: "Upstream", ID: "upstream", End: Plain("Not a mailbox")},
|
||||
Upstream: Box{No: "02", Title: "Upstream", ID: "upstream", End: Plain("Not a mailbox"), Help: topicLink(HelpUpstream, "Host, port and TLS")},
|
||||
UpstreamAction: href + "/upstream",
|
||||
Host: in.Host,
|
||||
Port: strconv.Itoa(in.Port),
|
||||
@@ -305,7 +305,7 @@ func NewInDomain(m Meta, in InDomainInput) *InDomain {
|
||||
{Value: tlsModeNone, Label: "Off"}},
|
||||
TLSSelected: in.TLSMode,
|
||||
|
||||
Recipients: Box{No: "03", Title: "Valid recipients", ID: "recipients"},
|
||||
Recipients: Box{No: "03", Title: "Valid recipients", ID: "recipients", Help: topicLink(HelpRecipients, "Listed addresses or any address")},
|
||||
RecipientsAction: href + "/recipients",
|
||||
ModeOptions: []Option{{Value: "list", Label: "Listed addresses only"},
|
||||
{Value: recipientModeAny, Label: "Any recipient at this domain"}},
|
||||
@@ -313,7 +313,7 @@ func NewInDomain(m Meta, in InDomainInput) *InDomain {
|
||||
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"},
|
||||
Filter: Box{No: "04", Title: "Spam filter", ID: "filter", Help: topicLink(HelpFilter, "A server setting")},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -360,9 +360,9 @@ func NewOutDomain(m Meta, id int64, name string, canDelete bool) *OutDomain {
|
||||
},
|
||||
id: id, name: name, canDelete: canDelete,
|
||||
DNS: Box{No: "01", Title: "DNS records", ID: "dns",
|
||||
Help: &HelpLink{Href: "/help#dns", Title: "What to publish and why"}},
|
||||
Help: topicLink(HelpDNS, "What to publish and why")},
|
||||
Apps: Box{No: "02", Title: "Applications", ID: "apps", End: Plain("SASL logins that may send as this domain"),
|
||||
Help: &HelpLink{Href: "/help#apps", Title: "What an application is"}},
|
||||
Help: topicLink(HelpApps, "What an application is")},
|
||||
AppsEmpty: EmptyState{Icon: "ti-apps", Text: Plain("No applications yet. Add one to let a program send as this domain.")},
|
||||
Connection: Box{No: "03", Title: "Connection", ID: "connection", End: Plain("The same for every domain")},
|
||||
}
|
||||
@@ -610,17 +610,22 @@ func NewOutDomainSettings(m Meta, id int64, name string, apps int, canDelete boo
|
||||
Title: "Domain settings",
|
||||
Lead: Plain("The things set once and rarely touched: where DMARC reports go, the sending ceiling, moving the domain elsewhere."),
|
||||
},
|
||||
Reports: Box{No: "01", Title: "DMARC report address", ID: "reports"},
|
||||
Reports: Box{No: "01", Title: "DMARC report address", ID: "reports", Help: topicLink(HelpDMARC, "Where DMARC reports go")},
|
||||
ReportsAction: href + "/settings/reports",
|
||||
Export: Box{No: "03", Title: "Export domain", ID: "export"},
|
||||
ExportAction: href + "/settings/export",
|
||||
Encrypt: true,
|
||||
Limit: Box{No: "02", Title: "Rate limit", ID: "limit",
|
||||
Help: &HelpLink{Href: "/help#limits", Title: "Levels 1 and 2"}},
|
||||
Help: topicLink(HelpLimits, "Levels 1 and 2")},
|
||||
LimitAction: href + "/settings/ratelimit",
|
||||
RecalcAction: href + "/settings/ratelimit/recalc",
|
||||
CanDelete: canDelete,
|
||||
}
|
||||
// The topic about exports is in the Server group of Help, which a domain
|
||||
// administrator is not shown, so the box links to it for the global role only.
|
||||
if m.IsGlobal {
|
||||
p.Export.Help = topicLink(HelpBackup, "Moving a domain to another instance")
|
||||
}
|
||||
if canDelete {
|
||||
text := "Removes the DKIM key"
|
||||
if n := appsPhrase(apps); n != "" {
|
||||
|
||||
@@ -123,8 +123,8 @@ func NewOutApp(m Meta, domainID int64, domain, login string) *OutApp {
|
||||
p := &OutApp{
|
||||
Cancel: href,
|
||||
Sender: Box{No: "01", Title: "Sender"},
|
||||
IPs: Box{No: "02", Title: "Client IP allow-list"},
|
||||
Limit: Box{No: "03", Title: "Rate limit"},
|
||||
IPs: Box{No: "02", Title: "Client IP allow-list", Help: topicLink(HelpApps, "What an application is")},
|
||||
Limit: Box{No: "03", Title: "Rate limit", Help: topicLink(HelpLimits, "Levels 1 and 2")},
|
||||
Modes: []Option{
|
||||
{Value: AddressWildcard, Label: "Any address of the domain"},
|
||||
{Value: AddressList, Label: "Specific addresses (list)"},
|
||||
|
||||
@@ -104,7 +104,7 @@ func NewOutLog(m Meta, retentionDays int, canSettings bool, domains, apps []stri
|
||||
return &OutLog{
|
||||
Meta: m,
|
||||
Head: Head{Kicker: "Outbound", Title: "Outbound log", Lead: lead},
|
||||
Messages: Box{No: "01", Title: "Messages", Filter: &Filter{
|
||||
Messages: Box{No: "01", Title: "Messages", Help: topicLink(HelpLog, "What the statuses mean"), Filter: &Filter{
|
||||
Action: outboundLog,
|
||||
Selects: []Select{
|
||||
{Name: "domain", Label: "Domain", Options: options("All domains", domains), Selected: domain},
|
||||
@@ -238,7 +238,7 @@ func NewOutMessage(m Meta, in MessageInput) *OutMessage {
|
||||
{Label: "Queue id", Value: Plain(orDash(in.QueueID)), Mono: true},
|
||||
{Label: "Journal id", Value: Plain(strconv.FormatInt(in.ID, 10)), Mono: true},
|
||||
},
|
||||
History: Box{No: "02", Title: "History"},
|
||||
History: Box{No: "02", Title: "History", Help: topicLink(HelpLog, "What the statuses mean")},
|
||||
Delivery: Box{No: "03", Title: "Delivery log", End: Rich("Lines from ", Code("mail.log"), " with this queue id")},
|
||||
LogEmpty: EmptyState{Icon: "ti-file-text"},
|
||||
}
|
||||
|
||||
@@ -45,7 +45,8 @@ func NewOutQueue(m Meta, listing, errText string) *OutQueue {
|
||||
Lead: Rich("What applications handed over and the receiving server has not yet taken, as ", Code("postqueue -p"), " lists it."),
|
||||
},
|
||||
Queue: Box{No: "01", Title: "Waiting", ID: "out-queue", Poll: outboundQueue + "/fragment",
|
||||
End: Plain("Refreshes on its own")},
|
||||
Help: topicLink(HelpQueue, "What waits here and how Postfix retries"),
|
||||
End: Plain("Refreshes on its own")},
|
||||
Empty: EmptyState{Icon: "ti-stack-2", Text: Plain("Queue is empty.")},
|
||||
Retries: Box{No: "02", Title: "How retries work"},
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ func NewOverview(m Meta, host string, cards []HealthCard, updated time.Time, day
|
||||
Head: overviewHead(host, cards, updated),
|
||||
Health: Box{No: "01", Title: "Server health", ID: "health", Poll: "/overview/fragment",
|
||||
End: Rich(Link("/server/health", "All details")),
|
||||
Help: &HelpLink{Href: "/help#checks", Title: "What these checks mean"}},
|
||||
Help: topicLink(HelpChecks, "What these checks mean")},
|
||||
Outbound: Box{No: "02", Title: "Outbound domains", End: Rich(Link("/outbound/domains", "Manage"))},
|
||||
OutboundEmpty: EmptyState{Icon: "ti-world-upload",
|
||||
Text: Plain("No outbound domains yet. Add the first one under Outbound.")},
|
||||
|
||||
@@ -98,11 +98,11 @@ func NewBackup(m Meta, passwordMin int, refusal string) *Backup {
|
||||
Title: "Backup",
|
||||
Lead: Plain("Take the whole instance with you, or bring one domain in from another SelfPost. Both files are secrets."),
|
||||
},
|
||||
Full: Box{No: "01", Title: "Full backup"},
|
||||
Full: Box{No: "01", Title: "Full backup", Help: topicLink(HelpBackup, "Backups, encryption and restore")},
|
||||
FullAction: serverBackup,
|
||||
Encrypt: true,
|
||||
PasswordMin: passwordMin,
|
||||
Import: Box{No: "02", Title: "Import a domain", ID: "import"},
|
||||
Import: Box{No: "02", Title: "Import a domain", ID: "import", Help: topicLink(HelpBackup, "Moving a domain to another instance")},
|
||||
ImportAction: serverBackup + "/import",
|
||||
}
|
||||
if refusal != "" {
|
||||
@@ -198,7 +198,7 @@ func NewUsers(m Meta, inbound bool, flash string) *Users {
|
||||
Em("global"), " is the whole server, ", Em("domain"), " is ", reach),
|
||||
Actions: []Action{{Label: "Create user", Icon: "ti-user-plus", Href: serverUsers + "/new", Primary: true}},
|
||||
},
|
||||
List: Box{No: "01", Title: "Panel users"},
|
||||
List: Box{No: "01", Title: "Panel users", Help: topicLink(HelpUsers, "Roles are a reach, not a rank")},
|
||||
ShowInbound: inbound,
|
||||
}
|
||||
if flash != "" {
|
||||
@@ -298,7 +298,7 @@ func NewUserForm(m Meta, in UserFormInput) *UserForm {
|
||||
Head: Head{Crumbs: crumbs, Title: title},
|
||||
CancelHref: serverUsers,
|
||||
|
||||
Account: Box{No: "01", Title: "Account"},
|
||||
Account: Box{No: "01", Title: "Account", Help: topicLink(HelpUsers, "Roles and domain assignments")},
|
||||
Username: in.Username,
|
||||
Email: in.Email,
|
||||
NewUser: in.ID == 0,
|
||||
|
||||
@@ -259,11 +259,23 @@ func TestUserDeleteNamesWhatGoesWithTheUser(t *testing.T) {
|
||||
|
||||
// ---- Help
|
||||
|
||||
var helpLink = regexp.MustCompile(`class="sp-help" href="/help#([a-z-]+)"`)
|
||||
var (
|
||||
helpLink = regexp.MustCompile(`class="sp-help" href="/help#([a-z-]+)"`)
|
||||
helpTopic = regexp.MustCompile(`class="sp-help-topic" id="([a-z-]+)"`)
|
||||
)
|
||||
|
||||
// menuOnlyTopics are the topics of the Help page that no box of a fixture page
|
||||
// links to, each with the reason it is reached from the menu alone. The rest of
|
||||
// the Help tests are in help_test.go.
|
||||
var menuOnlyTopics = map[string]string{
|
||||
HelpAccount: "the Account page is its own topic: its boxes (profile, password) explain themselves, so no box leads to it",
|
||||
}
|
||||
|
||||
// A box's Help link is only worth having if the Help page has the topic it
|
||||
// names. Every page of the panel is rendered with its fixture, and every anchor
|
||||
// it links to must be a topic of the Help page.
|
||||
// names, and a topic is only worth having if something leads to it. Every page
|
||||
// of the panel is rendered with its fixture; every anchor it links to must be a
|
||||
// topic of the Help page, and every topic must be linked from a page, except
|
||||
// those in menuOnlyTopics.
|
||||
func TestEveryHelpLinkLandsOnATopic(t *testing.T) {
|
||||
help := renderSignedIn(t, "help", NewHelp(admin(), true))
|
||||
linked := map[string][]string{}
|
||||
@@ -275,37 +287,29 @@ func TestEveryHelpLinkLandsOnATopic(t *testing.T) {
|
||||
if len(linked) == 0 {
|
||||
t.Fatal("no page links to a Help topic, so nothing was checked")
|
||||
}
|
||||
topics := map[string]bool{}
|
||||
for _, m := range helpTopic.FindAllStringSubmatch(help, -1) {
|
||||
topics[m[1]] = true
|
||||
}
|
||||
for anchor, pages := range linked {
|
||||
if !strings.Contains(help, `class="sp-help-topic" id="`+anchor+`"`) {
|
||||
if !topics[anchor] {
|
||||
t.Errorf("%v link to /help#%s, which is not a topic of the Help page", pages, anchor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The menu of the Help page jumps to topics that exist, and every topic is in it.
|
||||
func TestHelpMenuAndTopicsAgree(t *testing.T) {
|
||||
for _, global := range []bool{true, false} {
|
||||
out := renderSignedIn(t, "help", NewHelp(admin(), global))
|
||||
topics := regexp.MustCompile(`class="sp-help-topic" id="([a-z-]+)"`).FindAllStringSubmatch(out, -1)
|
||||
menu := regexp.MustCompile(`<a href="#([a-z-]+)">`).FindAllStringSubmatch(out, -1)
|
||||
if len(topics) != len(menu) || len(topics) == 0 {
|
||||
t.Fatalf("global=%v: %d topics, %d menu entries", global, len(topics), len(menu))
|
||||
for topic := range topics {
|
||||
reason, menuOnly := menuOnlyTopics[topic]
|
||||
switch {
|
||||
case len(linked[topic]) == 0 && !menuOnly:
|
||||
t.Errorf("Help topic %q is linked from no page: link it from the box it explains, or list it in menuOnlyTopics with the reason", topic)
|
||||
case len(linked[topic]) > 0 && menuOnly:
|
||||
t.Errorf("Help topic %q is listed as menu-only (%s) but %v link to it", topic, reason, linked[topic])
|
||||
}
|
||||
}
|
||||
for topic := range menuOnlyTopics {
|
||||
if !topics[topic] {
|
||||
t.Errorf("menuOnlyTopics names %q, which is not a topic of the Help page", topic)
|
||||
}
|
||||
for i := range topics {
|
||||
if topics[i][1] != menu[i][1] {
|
||||
t.Errorf("global=%v: topic %d is %q, its menu entry points at %q", global, i, topics[i][1], menu[i][1])
|
||||
}
|
||||
}
|
||||
unique(t, "Help", ids(out))
|
||||
}
|
||||
}
|
||||
|
||||
// The topics about Overview and Health are for the role that sees those pages;
|
||||
// a domain administrator is not shown checks they cannot open.
|
||||
func TestHelpLeavesOutTheOverviewForADomainAdministrator(t *testing.T) {
|
||||
out := renderSignedIn(t, "help", NewHelp(Meta{User: "ops", HasOutbound: true}, false))
|
||||
pageHas(t, "Help", out, `id="dns"`, `id="apps"`, `id="limits"`, `<h2>Outbound</h2>`)
|
||||
pageLacks(t, "Help", out, `id="checks"`, `id="certificate"`, `id="rdns"`, `<h2>Overview</h2>`, `href="/server/health"`)
|
||||
}
|
||||
|
||||
// The e-mail help says only what is so: it is the user's own address, they can
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{{/* Help (mockup help.html): one topic per heading, stacked in two boxes, with a
|
||||
menu that jumps to each. A box on another page links to the topic that
|
||||
explains it by the topic's id (Box.Help). Overview is absent for a viewer
|
||||
who does not see the pages it explains. */}}
|
||||
{{/* Help (mockup help.html): one topic per heading, stacked in one box per section
|
||||
of the panel, with a menu that jumps to each. A box on another page links to
|
||||
the topic that explains it by the topic's id (Box.Help). A section is absent
|
||||
for a viewer who cannot open its pages, and so is its part of the menu. */}}
|
||||
{{define "content"}}
|
||||
{{template "page_head" .Head}}
|
||||
|
||||
@@ -10,16 +10,12 @@
|
||||
{{template "side_menu" .Menu}}
|
||||
</div>
|
||||
<div class="column is-9">
|
||||
{{if .ShowOverview}}
|
||||
{{template "box_open" .Overview}}
|
||||
{{range .OverviewTopics}}{{template "help_topic" .}}
|
||||
{{range .Groups}}
|
||||
{{template "box_open" .Box}}
|
||||
{{range .Topics}}{{template "help_topic" .}}
|
||||
{{end}}
|
||||
{{template "box_close"}}
|
||||
{{end}}
|
||||
{{template "box_open" .Outbound}}
|
||||
{{range .OutboundTopics}}{{template "help_topic" .}}
|
||||
{{end}}
|
||||
{{template "box_close"}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user