panel-notifications: Account, Server > Settings, Server > Health, help; e2e
test / test (push) Canceled after 0s
test / test (push) Canceled after 0s
The pages of the mockups accepted in 87fa0a4, built from the kit's partials
(written by a Sonnet agent into the working tree, reviewed here):
- Account: "Send me notifications" in the Profile form, saved with the
profile; an unticked box is off.
- Server > Settings: the instance switch (box 03), and box 04 Notification
mail with the DKIM and SPF records of the server's own name, drawn with the
record partial of a domain's DNS page and checked in DNS. Sending rate
limits are box 02 in the left column. A record that is not in place is
tagged as a warning, not a failure: notifications are sent without it.
- Server > Health: card 06 Notification mail with the state of each record
and a link to Settings, beside Configuration (07); part of the polled
fragment; Re-check DNS forces it too. Overview does not run this check.
- help.go: the Notifications topic and the clauses in Settings and Your
account, word for word with help.html.
If the server's key cannot be read the pages still render: the DKIM record
is left out and SPF is shown unchecked.
e2e: the administrator gets a mailbox behind the sink and the server's DKIM
record from Settings is published; the level-2 refusal of the next step must
then arrive as a notification from postmaster@mail.e2e.test, signed as that
name with the key Settings shows, and must not be a row of the Outbound log.
The rotation step now waits for Postfix to be running: it follows a step that
restarts the container, and logrotate's `postfix reload` failed in between.
On the test server, for this tree: go vet clean, go test ./... 23 packages
ok, make e2e ok (147 s); the notification reached the sink 28 s after the
refusal.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,9 @@ type Account struct {
|
||||
Profile Box
|
||||
Username string
|
||||
Email string
|
||||
// Notify is whether the panel writes to this user: the checkbox under the
|
||||
// e-mail.
|
||||
Notify bool
|
||||
|
||||
Password Box
|
||||
}
|
||||
@@ -63,6 +66,18 @@ type Settings struct {
|
||||
|
||||
RateLimits Box
|
||||
RateFacts []Fact
|
||||
|
||||
// Notifications is the instance switch for notification e-mails; Notify is
|
||||
// its state and NotifyNote the line under it, which says from where and to
|
||||
// whom.
|
||||
Notifications Box
|
||||
Notify bool
|
||||
NotifyNote Text
|
||||
|
||||
// Mail holds the DNS records of the server's own name, which notifications
|
||||
// are signed and authorised with, drawn like a domain's.
|
||||
Mail Box
|
||||
MailRecords []Record
|
||||
}
|
||||
|
||||
// NewSettings builds the page. retention is what the field shows (the stored
|
||||
@@ -87,9 +102,28 @@ func NewSettings(m Meta, retention, level1 string) *Settings {
|
||||
{Label: "Level 2 · domain, application",
|
||||
Value: Rich("Set on each ", Link("/outbound/domains", "domain"), " and on each application of a domain.")},
|
||||
},
|
||||
Notifications: Box{No: "03", Title: "Notifications", ID: "notifications", Help: topicLink(HelpNotifications, "What is sent and when")},
|
||||
Mail: Box{No: "04", Title: "Notification mail", ID: "notification-mail", End: Plain("DNS records of the server's own name"),
|
||||
Help: topicLink(HelpNotifications, "What the panel sends and what makes it arrive")},
|
||||
}
|
||||
}
|
||||
|
||||
// WithNotifications sets the instance switch: on is whether notification
|
||||
// e-mails are sent at all, from is the address they come from.
|
||||
func (s *Settings) WithNotifications(on bool, from string) *Settings {
|
||||
s.Notify = on
|
||||
s.NotifyNote = Rich("From ", Mono(from), ", to every user who has an e-mail and has not turned them off under ",
|
||||
Link("/account", "Account"), ".")
|
||||
return s
|
||||
}
|
||||
|
||||
// WithMail sets the records of the server's own name, in the order they are
|
||||
// shown (ServerMailDKIM, ServerMailSPF).
|
||||
func (s *Settings) WithMail(records []Record) *Settings {
|
||||
s.MailRecords = records
|
||||
return s
|
||||
}
|
||||
|
||||
// WithInbound adds the retention of the Inbound log to the Log retention box.
|
||||
// days is what the field shows, like retention in NewSettings. The field exists
|
||||
// only while the inbound relay is on: with it off there is no journal to keep.
|
||||
|
||||
@@ -2,6 +2,7 @@ package view
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -119,6 +120,10 @@ func healthFixture(refresh bool) *Health {
|
||||
{Label: "Hostname", Value: Plain("mail.example.org"), Mono: true},
|
||||
{Label: "Lookup", Value: Rich("→ 203.0.113.25", Br(), "← mail.example.org"), Mono: true},
|
||||
}
|
||||
h.WithNotificationMail("postmaster@mail.example.org", []MailRow{
|
||||
NewMailRow("DKIM", mailFixtureDKIMHost, DNSCheck{Status: "ok"}),
|
||||
NewMailRow("SPF", "mail.example.org", DNSCheck{Status: "error"}),
|
||||
}, "warn")
|
||||
return h
|
||||
}
|
||||
|
||||
@@ -126,14 +131,25 @@ func healthFixture(refresh bool) *Health {
|
||||
// and e-mail.
|
||||
func accountFixture() *Account {
|
||||
a := NewAccount(admin(), "global")
|
||||
a.Username, a.Email = "admin", "mix@example.org"
|
||||
a.Username, a.Email, a.Notify = "admin", "mix@example.org", true
|
||||
return a
|
||||
}
|
||||
|
||||
// mailFixtureDKIMHost is where the mockup publishes the DKIM key of the server's
|
||||
// own name.
|
||||
const mailFixtureDKIMHost = "sp2026._domainkey.mail.example.org"
|
||||
|
||||
// settingsFixture is the mockup's Settings: thirty days of log, a limit of 600
|
||||
// messages an hour.
|
||||
// messages an hour, notifications on, and the records of the server's own name
|
||||
// with the DKIM one published and the SPF one missing.
|
||||
func settingsFixture() *Settings {
|
||||
return NewSettings(admin(), "30", FormatRate(600, 3600)).WithInbound("90")
|
||||
key := "v=DKIM1; k=rsa; p=" + strings.Repeat("MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A", 10) + "IDAQAB"
|
||||
return NewSettings(admin(), "30", FormatRate(600, 3600)).WithInbound("90").
|
||||
WithNotifications(true, "postmaster@mail.example.org").
|
||||
WithMail([]Record{
|
||||
ServerMailDKIM("sp2026", mailFixtureDKIMHost, key, DNSCheck{Status: "ok"}),
|
||||
ServerMailSPF("mail.example.org", "v=spf1 a -all", DNSCheck{Status: "error", Detail: "No SPF record is published for mail.example.org."}),
|
||||
})
|
||||
}
|
||||
|
||||
// outDomainsFixture is the mockup's Outbound domains: three domains, the first
|
||||
|
||||
@@ -30,10 +30,19 @@ type Health struct {
|
||||
CertDetail string
|
||||
CertProblem bool
|
||||
|
||||
Sockets Box
|
||||
SocketRows []SocketRow
|
||||
Hostname Box
|
||||
HostFacts []Fact
|
||||
Sockets Box
|
||||
SocketRows []SocketRow
|
||||
Hostname Box
|
||||
HostFacts []Fact
|
||||
|
||||
// NotifyMail is the card of the DNS records notifications are signed and
|
||||
// authorised with (their values are under Server › Settings); NotifyNote
|
||||
// is the line under its table. It is polled with the rest, so Refresh swaps
|
||||
// it into place on its own.
|
||||
NotifyMail Box
|
||||
NotifyRows []MailRow
|
||||
NotifyNote Text
|
||||
|
||||
Configuration Box
|
||||
Reload Action
|
||||
}
|
||||
@@ -87,6 +96,20 @@ type SocketRow struct {
|
||||
Detail string
|
||||
}
|
||||
|
||||
// MailRow is one record of the Notification mail table: its name, the host it
|
||||
// is published at and its state as a tag.
|
||||
type MailRow struct {
|
||||
Name string
|
||||
Host string
|
||||
State Tag
|
||||
}
|
||||
|
||||
// NewMailRow is the row of one record of the server's own name, from what the
|
||||
// DNS check found. A record that is not in place is a warning, as on Settings.
|
||||
func NewMailRow(name, host string, c DNSCheck) MailRow {
|
||||
return MailRow{Name: name, Host: host, State: mailTag(c)}
|
||||
}
|
||||
|
||||
// NewHealth builds the page around its content. The handler fills the tables;
|
||||
// the boxes, the head and the reload action are the page's own.
|
||||
func NewHealth(m Meta, flash string) *Health {
|
||||
@@ -104,7 +127,9 @@ func NewHealth(m Meta, flash string) *Health {
|
||||
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", Help: topicLink(HelpRDNS, "Forward and reverse DNS")},
|
||||
Configuration: Box{No: "06", Title: "Configuration", ID: "configuration",
|
||||
NotifyMail: Box{No: "06", Title: "Notification mail", ID: "notification-mail",
|
||||
Help: topicLink(HelpNotifications, "What the panel sends and what makes it arrive")},
|
||||
Configuration: Box{No: "07", Title: "Configuration", ID: "configuration",
|
||||
End: Plain("Safe to run at any time")},
|
||||
Reload: Action{Label: "Reload configuration", Icon: "ti-reload", Post: "/server/health/reload"},
|
||||
}
|
||||
@@ -114,5 +139,16 @@ func NewHealth(m Meta, flash string) *Health {
|
||||
return h
|
||||
}
|
||||
|
||||
// WithNotificationMail fills the Notification mail card: from is the address
|
||||
// notifications come from, rows the state of each record, and status the
|
||||
// card's verdict for its head.
|
||||
func (h *Health) WithNotificationMail(from string, rows []MailRow, status string) *Health {
|
||||
h.NotifyMail.End = Verdict(status)
|
||||
h.NotifyRows = rows
|
||||
h.NotifyNote = Rich("Sent from ", Mono(from), ". What to publish is under ",
|
||||
Link("/server/settings#notification-mail", "Settings"), ".")
|
||||
return h
|
||||
}
|
||||
|
||||
// Verdict puts a check's status in the end slot of its box, as the mockup does.
|
||||
func Verdict(status string) Text { return Text{TagOf(status, "")} }
|
||||
|
||||
@@ -59,6 +59,9 @@ const (
|
||||
HelpBackup = "backup"
|
||||
HelpUsers = "users"
|
||||
HelpSettings = "settings"
|
||||
// HelpNotifications is what the panel writes to its users and what makes
|
||||
// that mail arrive; Account, Settings and Health link to it.
|
||||
HelpNotifications = "notifications"
|
||||
// Account
|
||||
HelpAccount = "account"
|
||||
)
|
||||
@@ -121,6 +124,7 @@ func NewHelp(m Meta, inbound bool) *Help {
|
||||
{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},
|
||||
{Label: "Notifications", Icon: "ti-mail", Href: "#" + HelpNotifications},
|
||||
})
|
||||
}
|
||||
add("Account", accountTopics, []MenuItem{
|
||||
@@ -233,12 +237,19 @@ var serverTopics = []HelpTopic{
|
||||
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. With the inbound relay on there is a second number, for the Inbound log, with the same range. 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."),
|
||||
Code(".env"), ": changing it needs a restart, and no level-2 limit can exceed it. Notifications are turned on or off here for everyone."),
|
||||
}},
|
||||
{ID: HelpNotifications, Title: "Notifications", Body: []Text{
|
||||
Rich("The panel writes to its users when something needs them, from ", Code("postmaster@"), " the server's hostname. Four things are reported. A sending domain's SPF, DKIM or DMARC record stops matching: sent once two hourly checks in a row have found it so, and once more when the record matches again. A level-2 rate limit refuses mail: sent at the first refusal, then at most once a day for the same limit. The TLS certificate is 14 days from expiry, and again at 3 days if it is still the same certificate. A message has waited in the queue for more than three days, with one more message when none is left. A global user is told all of it; a domain administrator is told about the DNS records and limits of their own domains.",
|
||||
Br(), Br(),
|
||||
"Each user turns notifications on or off under Account and needs an e-mail there; Server › Settings turns them off for everyone. They are the panel's own mail: not rows of the Outbound log, and not counted against any limit.",
|
||||
Br(), Br(),
|
||||
"For that mail to arrive, the server's own name needs two DNS records of its own, as each of your domains does: a DKIM key, which the panel generates and signs notifications with, and an SPF record, ", Code("v=spf1 a -all"), ". Server › Settings gives both with the values to publish, and Server › Health shows whether they are in place. Without them notifications are still sent, and a receiver may file them as spam. Mail of your domains does not depend on either."),
|
||||
}},
|
||||
}
|
||||
|
||||
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."),
|
||||
Plain("Account is your own, whatever your role: your username and e-mail, whether the panel sends you notifications, 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."),
|
||||
}},
|
||||
}
|
||||
|
||||
@@ -178,6 +178,41 @@ func SPFRecord(domain, value string, c DNSCheck) Record {
|
||||
return r
|
||||
}
|
||||
|
||||
// mailTag is the tag of a record of the server's own name. The records are not
|
||||
// needed for notifications to be sent, only for them to arrive, so a record
|
||||
// that is missing, wrong or weak is a warning here and never the red a
|
||||
// sending domain's would be; published and not checked keep their meaning.
|
||||
func mailTag(c DNSCheck) Tag {
|
||||
t := DNSStatusTag(c.Status, len(c.Found) > 0)
|
||||
if tagLevel(t.Status) == LevelFail {
|
||||
t.Status = "warn"
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// ServerMailDKIM is the DKIM record of the server's own name, which the panel
|
||||
// signs its notifications with: a domain's DKIM record in every other respect.
|
||||
func ServerMailDKIM(selector, host, value string, c DNSCheck) Record {
|
||||
r := DKIMRecord(selector, host, value, c)
|
||||
r.Status = mailTag(c)
|
||||
return r
|
||||
}
|
||||
|
||||
// ServerMailSPF is the SPF record of the server's own name. Without it
|
||||
// notifications are still sent, which is said under the value while it is not
|
||||
// in place.
|
||||
func ServerMailSPF(host, value string, c DNSCheck) Record {
|
||||
r := Record{Name: "SPF", Note: Plain("For the server's own name, not for your domains"),
|
||||
Host: host, Type: "TXT", Value: value}
|
||||
var standing Text
|
||||
if c.Status != "ok" {
|
||||
standing = Plain("Notifications are sent without it, and a receiver may file them as spam.")
|
||||
}
|
||||
applyCheck(&r, c, standing, nil)
|
||||
r.Status = mailTag(c)
|
||||
return r
|
||||
}
|
||||
|
||||
// DMARCRecordInput is what a DMARC record needs besides its check: where the
|
||||
// reports go in words (Source: the address, or "no reports") and the page that changes it,
|
||||
// the page of the reports themselves ("" when the panel has none), and whether
|
||||
|
||||
@@ -148,7 +148,7 @@ func TestHealthShowsEveryCheckAndBothActions(t *testing.T) {
|
||||
// the two actions: Re-check DNS in the head, Reload configuration under the tables
|
||||
`action="/server/health/recheck"`, `action="/server/health/reload"`,
|
||||
// the anchors the Overview cards lead to
|
||||
`id="machine"`, `id="processes"`, `id="certificate"`, `id="sockets"`, `id="hostname"`, `id="configuration"`,
|
||||
`id="machine"`, `id="processes"`, `id="certificate"`, `id="sockets"`, `id="hostname"`, `id="notification-mail"`, `id="configuration"`,
|
||||
// the machine card: bars carry their reading in an attribute (the CSP rules out sizing them with a style)
|
||||
`<progress class="progress is-small is-success mb-1" value="12" max="100">`, `value="41"`,
|
||||
`↓ 18.0 KiB/s`, `eth0: 2.1 GiB in, 6.4 GiB out`,
|
||||
@@ -158,8 +158,8 @@ func TestHealthShowsEveryCheckAndBothActions(t *testing.T) {
|
||||
`hx-get="/server/health/fragment"`,
|
||||
)
|
||||
pageLacks(t, "Health", out, `hx-swap-oob`, `/overview/fragment`)
|
||||
if got := strings.Count(out, `class="columns"`); got != 2 {
|
||||
t.Errorf("Health has %d rows of boxes, want 2", got)
|
||||
if got := strings.Count(out, `class="columns"`); got != 3 {
|
||||
t.Errorf("Health has %d rows of boxes, want 3", got)
|
||||
}
|
||||
// The Configuration box is outside the polled rows: a refresh must not re-render a control.
|
||||
if strings.Index(out, `id="configuration"`) < strings.Index(out, `id="health-checks"`) ||
|
||||
@@ -169,29 +169,54 @@ func TestHealthShowsEveryCheckAndBothActions(t *testing.T) {
|
||||
}
|
||||
|
||||
// The fragment is the polled part of the page and nothing else: the same rows,
|
||||
// the second one marked to be swapped in by its id, no shell and no actions.
|
||||
// the second one marked to be swapped in by its id, and the Notification mail
|
||||
// card, a column of the last row, swapped in the same way. No shell and no
|
||||
// actions.
|
||||
func TestHealthFragmentIsThePolledRows(t *testing.T) {
|
||||
page := renderSignedIn(t, "health", healthFixture(false))
|
||||
frag := renderFragment(t, "health_body", healthFixture(true))
|
||||
|
||||
const card = `<div class="column is-6" id="health-mail"`
|
||||
// The part of s before the card, and the card itself up to what follows it
|
||||
// on the page (the Configuration box).
|
||||
rows := func(s string) string {
|
||||
a := strings.Index(s, `<div class="columns" id="health-machine"`)
|
||||
b := strings.Index(s, `id="configuration"`)
|
||||
if b < 0 {
|
||||
b = len(s)
|
||||
}
|
||||
return s[a:b]
|
||||
return s[strings.Index(s, `<div class="columns" id="health-machine"`):strings.Index(s, card)]
|
||||
}
|
||||
// Same markup, apart from the out-of-band mark and what follows the rows on the page.
|
||||
got := strings.Replace(strings.TrimSpace(frag), ` hx-swap-oob="true"`, "", 1)
|
||||
want := strings.TrimSpace(rows(page))
|
||||
if !strings.HasPrefix(want, got) {
|
||||
mail := func(s string) string {
|
||||
a := strings.Index(s, card)
|
||||
if b := strings.Index(s, `id="configuration"`); b > a {
|
||||
return s[a:b]
|
||||
}
|
||||
return s[a:]
|
||||
}
|
||||
// Same markup, apart from the out-of-band marks and what follows the card on the page.
|
||||
oob := func(s string) string { return strings.ReplaceAll(strings.TrimSpace(s), ` hx-swap-oob="true"`, "") }
|
||||
if got, want := oob(rows(frag)), oob(rows(page)); !strings.HasPrefix(want, got) {
|
||||
t.Errorf("the fragment is not the page's polled rows\nfragment: %.400s\npage: %.400s", got, want)
|
||||
}
|
||||
pageHas(t, "fragment", frag, `id="health-machine" data-poll`, `id="health-checks" hx-swap-oob="true"`)
|
||||
if got, want := oob(mail(frag)), oob(mail(page)); !strings.HasPrefix(want, got) {
|
||||
t.Errorf("the fragment's Notification mail card is not the page's\nfragment: %.400s\npage: %.400s", got, want)
|
||||
}
|
||||
pageHas(t, "fragment", frag, `id="health-machine" data-poll`, `id="health-checks" hx-swap-oob="true"`,
|
||||
`id="health-mail" hx-swap-oob="true"`, `id="notification-mail"`)
|
||||
pageLacks(t, "fragment", frag, `<h1`, `navbar`, `action="/server/health/reload"`, `action="/server/health/recheck"`)
|
||||
}
|
||||
|
||||
// The Notification mail card says which record is in place and which is not,
|
||||
// with the host each is published at; a missing record is a warning, since
|
||||
// notifications are sent without it. The way to the values is a link.
|
||||
func TestHealthNotificationMailCard(t *testing.T) {
|
||||
out := renderSignedIn(t, "health", healthFixture(false))
|
||||
pageHas(t, "Health", out,
|
||||
`<h2>Notification mail</h2><div class="sp-end"><span class="tag is-warning is-light">warn</span></div>`,
|
||||
`href="/help#notifications"`,
|
||||
`DKIM<br><span class="sp-muted sp-mono sp-small">sp2026._domainkey.mail.example.org</span></td><td><span class="tag is-success is-light">published</span>`,
|
||||
`SPF<br><span class="sp-muted sp-mono sp-small">mail.example.org</span></td><td><span class="tag is-warning is-light">missing</span>`,
|
||||
`Sent from <span class="sp-mono">postmaster@mail.example.org</span>`,
|
||||
`href="/server/settings#notification-mail"`,
|
||||
)
|
||||
}
|
||||
|
||||
// A machine whose counters could not be read keeps its rows: the figure becomes
|
||||
// a dash and the reason stays in the detail column.
|
||||
func TestHealthMachineWithoutReadings(t *testing.T) {
|
||||
@@ -289,15 +314,17 @@ func TestAccountEscapesTheFormValues(t *testing.T) {
|
||||
pageLacks(t, "Account", out, `<script>1`, `"onfocus="x`)
|
||||
}
|
||||
|
||||
// Event notifications are not built (roadmap: panel-notifications), and a page
|
||||
// carries no box for a feature that does not exist.
|
||||
func TestAccountAndSettingsHaveNoNotificationsBox(t *testing.T) {
|
||||
for name, out := range map[string]string{
|
||||
"Account": renderSignedIn(t, "account", accountFixture()),
|
||||
"Settings": renderSignedIn(t, "settings", settingsFixture()),
|
||||
} {
|
||||
pageLacks(t, name, out, `<h2>Notifications</h2>`, `type="checkbox"`, `/account/notifications`, `name="notify"`)
|
||||
}
|
||||
// The checkbox under the e-mail is the user's own switch for notifications: it
|
||||
// shows what is stored, and an unchecked box is the absence of the attribute.
|
||||
func TestAccountShowsTheNotifyCheckbox(t *testing.T) {
|
||||
a := accountFixture()
|
||||
out := renderSignedIn(t, "account", a)
|
||||
pageHas(t, "Account", out, `<input type="checkbox" name="notify" checked> Send me notifications`, `href="/help#notifications"`)
|
||||
|
||||
a.Notify = false
|
||||
out = renderSignedIn(t, "account", a)
|
||||
pageHas(t, "Account", out, `<input type="checkbox" name="notify"> Send me notifications`)
|
||||
pageLacks(t, "Account", out, `name="notify" checked`)
|
||||
}
|
||||
|
||||
// ---- Settings
|
||||
@@ -310,6 +337,54 @@ func TestSettingsIsOneFormWithOneSaveButton(t *testing.T) {
|
||||
pageHas(t, "Settings", out, `Save settings`, `<h1 class="title is-3">Settings</h1>`, `href="/account"`)
|
||||
}
|
||||
|
||||
// Notifications are one switch for the instance, saved with the rest of the
|
||||
// page, and the records of the server's own name are drawn like a domain's.
|
||||
func TestSettingsNotificationsSwitchAndRecords(t *testing.T) {
|
||||
s := settingsFixture()
|
||||
out := renderSignedIn(t, "settings", s)
|
||||
pageHas(t, "Settings", out,
|
||||
`<input type="checkbox" name="notify" checked> Send notification e-mails`,
|
||||
`From <span class="sp-mono">postmaster@mail.example.org</span>, to every user who has an e-mail and has not turned them off under <a href="/account">Account</a>.`,
|
||||
`id="notification-mail"`, `href="/help#notifications"`,
|
||||
// DKIM is published; SPF is not, and says what that costs.
|
||||
`<h3>DKIM</h3><span class="tag is-success is-light">published</span><small>Not a secret · selector <code>sp2026</code></small>`,
|
||||
`<h3>SPF</h3><span class="tag is-warning is-light">missing</span><small>For the server's own name, not for your domains</small>`,
|
||||
`value="sp2026._domainkey.mail.example.org"`, `value="v=spf1 a -all"`,
|
||||
`Notifications are sent without it, and a receiver may file them as spam.`,
|
||||
)
|
||||
|
||||
// Switched off, and both records in place: the box is unchecked and the
|
||||
// warning under SPF is gone.
|
||||
s.Notify = false
|
||||
s.WithMail([]Record{
|
||||
ServerMailDKIM("sp2026", mailFixtureDKIMHost, "v=DKIM1; k=rsa; p=AAAA", DNSCheck{Status: "ok"}),
|
||||
ServerMailSPF("mail.example.org", "v=spf1 a -all", DNSCheck{Status: "ok"}),
|
||||
})
|
||||
out = renderSignedIn(t, "settings", s)
|
||||
pageHas(t, "Settings", out, `<input type="checkbox" name="notify"> Send notification e-mails`,
|
||||
`<h3>SPF</h3><span class="tag is-success is-light">published</span>`)
|
||||
pageLacks(t, "Settings", out, `name="notify" checked`, `a receiver may file them as spam`)
|
||||
}
|
||||
|
||||
// A record of the server's own name that is wrong or missing is a warning, never
|
||||
// the red of a sending domain's: notifications are sent without it.
|
||||
func TestMailRecordsAreWarningsNotErrors(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
check DNSCheck
|
||||
want Tag
|
||||
}{
|
||||
{DNSCheck{Status: "ok"}, Tag{Status: "published", Label: "published"}},
|
||||
{DNSCheck{Status: "error"}, Tag{Status: "warn", Label: "missing"}},
|
||||
{DNSCheck{Status: "error", Found: []string{"v=spf1 -all"}}, Tag{Status: "warn", Label: "mismatch"}},
|
||||
{DNSCheck{Status: "warn"}, Tag{Status: "warn", Label: "weak"}},
|
||||
{DNSCheck{Status: "unknown"}, Tag{Status: "unknown", Label: "not checked"}},
|
||||
} {
|
||||
if got := NewMailRow("SPF", "mail.example.org", c.check).State; got != c.want {
|
||||
t.Errorf("a record that checked as %+v is %+v, want %+v", c.check, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsShowsTheRefusalAndKeepsWhatWasTyped(t *testing.T) {
|
||||
s := NewSettings(admin(), "3", FormatRate(600, 3600)).WithResult("", "Send log retention must be between 7 and 365.")
|
||||
out := renderSignedIn(t, "settings", s)
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
<div class="field"><label class="label" for="username">Username</label><div class="control has-icons-left"><input class="input" id="username" name="username" value="{{.Username}}" autocomplete="username" autocapitalize="none" spellcheck="false" required><span class="icon is-left"><i class="ti ti-user"></i></span></div></div>
|
||||
<div class="field"><label class="label" for="email">E-mail</label><div class="control has-icons-left"><input class="input" id="email" name="email" type="email" value="{{.Email}}" autocomplete="email" autocapitalize="none" spellcheck="false"><span class="icon is-left"><i class="ti ti-mail"></i></span></div>
|
||||
<p class="help">Your own address. A domain's report address can be filled with it from Domain settings.</p></div>
|
||||
<div class="field"><label class="checkbox"><input type="checkbox" name="notify"{{if .Notify}} checked{{end}}> Send me notifications</label>
|
||||
<p class="help">To this address, when something needs you: a DNS record of a domain stops matching, a rate limit refuses mail, the TLS certificate is about to expire, a message has waited in the queue for three days. <a href="/help#notifications">What is sent and when</a></p></div>
|
||||
<button type="submit" class="button is-primary">Save profile</button>
|
||||
</form>
|
||||
{{template "body_close"}}
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
{{/* Server > Health (mockup health.html): every check behind the cards of
|
||||
Overview, and the one action that is not about DNS, Reload configuration. The
|
||||
two rows of tables are the polled part (health_body.html); the head and the
|
||||
Configuration box are static. */}}
|
||||
two rows of tables are the polled part (health_body.html), and so is the
|
||||
Notification mail card (health_mail), which sits in the last row beside the
|
||||
static Configuration box. */}}
|
||||
{{define "content"}}
|
||||
{{template "page_head" .Head}}
|
||||
{{if .Flash}}{{template "flash" .Flash}}{{end}}
|
||||
|
||||
{{template "health_body" .}}
|
||||
|
||||
{{template "box_open" .Configuration}}
|
||||
{{template "body_open"}}
|
||||
<p class="mb-3">Regenerates the OpenDKIM and Postfix configuration from the database and reloads both daemons. Use it after editing the files by hand, after restoring a backup, or when the running configuration looks out of step with the domain and application lists. It does not touch the queue or the TLS certificate.</p>
|
||||
{{template "action" .Reload}}
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
<div class="columns">
|
||||
{{template "health_mail" .}}
|
||||
<div class="column is-6">
|
||||
{{template "box_open" .Configuration}}
|
||||
{{template "body_open"}}
|
||||
<p class="mb-3">Regenerates the OpenDKIM and Postfix configuration from the database and reloads both daemons. Use it after editing the files by hand, after restoring a backup, or when the running configuration looks out of step with the domain and application lists. It does not touch the queue or the TLS certificate.</p>
|
||||
{{template "action" .Reload}}
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
fragment (GET /server/health/fragment) alike. panel.js polls the first row
|
||||
(data-poll, outerHTML) and htmx swaps the second into place by its id when
|
||||
the fragment marks it out of band (.Refresh) — the two rows are siblings, and
|
||||
a fragment can replace only the element that asked for it. */}}
|
||||
a fragment can replace only the element that asked for it. The Notification
|
||||
mail card is the third thing the fragment swaps in by id: it sits in a row of
|
||||
the page with the static Configuration box, so it is a column of its own. */}}
|
||||
{{define "health_body"}}<div class="columns" id="health-machine" data-poll aria-live="polite" hx-get="/server/health/fragment" hx-trigger="load" hx-swap="outerHTML">
|
||||
<div class="column is-6">
|
||||
{{template "box_open" .Machine}}
|
||||
@@ -58,4 +60,21 @@
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
</div>
|
||||
</div>{{if .Refresh}}
|
||||
{{template "health_mail" .}}{{end}}{{end}}
|
||||
|
||||
{{/* health_mail is the Notification mail card (the page calls it inside its own
|
||||
row; the fragment sends it out of band). */}}
|
||||
{{define "health_mail"}}<div class="column is-6" id="health-mail"{{if .Refresh}} hx-swap-oob="true"{{end}}>
|
||||
{{template "box_open" .NotifyMail}}
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Record</th><th>State</th></tr></thead>
|
||||
<tbody>{{range .NotifyRows}}
|
||||
<tr><td>{{.Name}}<br><span class="sp-muted sp-mono sp-small">{{.Host}}</span></td><td>{{template "tag" .State}}</td></tr>{{end}}
|
||||
</tbody>
|
||||
</table></div>
|
||||
{{template "body_open"}}
|
||||
<p class="sp-muted">{{template "inline" .NotifyNote}}</p>
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
</div>{{end}}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
{{/* Server > Settings (mockup settings.html): what holds for the whole instance.
|
||||
One form, one Save button; the rate limits are read-only facts. A user's own
|
||||
name, e-mail, password and DMARC address are on Account (account.html). */}}
|
||||
One form, one Save button; the rate limits are read-only facts, and the
|
||||
records of the server's own name are the ones notifications are signed and
|
||||
authorised with. A user's own name, e-mail, password and DMARC address are on
|
||||
Account (account.html). */}}
|
||||
{{define "content"}}
|
||||
{{template "page_head" .Head}}
|
||||
{{if .Flash}}{{template "flash" .Flash}}{{end}}
|
||||
@@ -19,15 +21,26 @@
|
||||
{{end}}
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
</div>
|
||||
|
||||
<div class="column is-6">
|
||||
{{template "box_open" .RateLimits}}
|
||||
{{template "body_open"}}
|
||||
{{template "facts" .RateFacts}}
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
</div>
|
||||
|
||||
<div class="column is-6">
|
||||
{{template "box_open" .Notifications}}
|
||||
{{template "body_open"}}
|
||||
<div class="field"><label class="checkbox"><input type="checkbox" name="notify"{{if .Notify}} checked{{end}}> Send notification e-mails</label>
|
||||
<p class="help">{{template "inline" .NotifyNote}}</p></div>
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
|
||||
{{template "box_open" .Mail}}
|
||||
{{range .MailRecords}}{{template "dns_record" .}}
|
||||
{{end}}{{template "box_close"}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="buttons"><button type="submit" class="button is-primary">Save settings</button></div>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user