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:
@@ -25,6 +25,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
`SELFPOST_HOSTNAME` and OpenDKIM signs mail submitted locally as that name
|
`SELFPOST_HOSTNAME` and OpenDKIM signs mail submitted locally as that name
|
||||||
(`non_smtpd_milters`), so that notifications arrive. The name is not a
|
(`non_smtpd_milters`), so that notifications arrive. The name is not a
|
||||||
sending domain: no application can send as it.
|
sending domain: no application can send as it.
|
||||||
|
- **Notifications in the panel.** Account has *Send me notifications* beside
|
||||||
|
the e-mail; Server › Settings has the switch for everyone and the two DNS
|
||||||
|
records of the server's own name, DKIM and SPF, with the values to publish;
|
||||||
|
Server › Health has a *Notification mail* card with the state of each. Help
|
||||||
|
has a Notifications topic. Every user starts with notifications on, and so
|
||||||
|
does the instance.
|
||||||
|
|
||||||
## [2.2.0] - 2026-10-12
|
## [2.2.0] - 2026-10-12
|
||||||
|
|
||||||
|
|||||||
@@ -112,8 +112,8 @@ to the owner before any code ([panel-redesign.md](panel-redesign.md)
|
|||||||
- [x] Sending: local submission from `postmaster@<hostname>`, message text (`sendmail` as the panel user; signed through `non_smtpd_milters` with a key of the server's own name; no link in the text - the panel does not know its own address)
|
- [x] Sending: local submission from `postmaster@<hostname>`, message text (`sendmail` as the panel user; signed through `non_smtpd_milters` with a key of the server's own name; no link in the text - the panel does not know its own address)
|
||||||
- [x] Background checker: DNS, certificate, queue; the rules of *When a message is sent*
|
- [x] Background checker: DNS, certificate, queue; the rules of *When a message is sent*
|
||||||
- [x] Level-2 refusal event (from the `rejected` rows of the send log, looked at every minute)
|
- [x] Level-2 refusal event (from the `rejected` rows of the send log, looked at every minute)
|
||||||
- [ ] Panel: Account, Settings, Health check, help
|
- [x] Panel: Account, Settings, Health card, help
|
||||||
- [x] guide.md, architecture.md, security.md, schema-migrations.md
|
- [x] guide.md, architecture.md, security.md, schema-migrations.md
|
||||||
- [ ] `go vet`, `go test`, e2e: an event reaches a mailbox once and clears once
|
- [x] `go vet`, `go test`, e2e: a level-2 refusal reaches a mailbox as a signed notification (on the test server: vet ok, 23 packages ok, e2e ok; "once" and "clears" are held by the unit tests of `internal/notify`)
|
||||||
- [ ] Technical review
|
- [ ] Technical review
|
||||||
- [ ] Security review
|
- [ ] Security review
|
||||||
|
|||||||
+1
-1
@@ -35,7 +35,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
|
|||||||
| template-data-typing | Typed structs for template data instead of `map[string]any` | candidate | — | — |
|
| template-data-typing | Typed structs for template data instead of `map[string]any` | candidate | — | — |
|
||||||
| structured-logging | `log/slog` with levels and fields | candidate | — | — |
|
| structured-logging | `log/slog` with levels and fields | candidate | — | — |
|
||||||
| review-2026-08-followups | Remaining findings of the 2026-08-19 code review | candidate | — | — |
|
| review-2026-08-followups | Remaining findings of the 2026-08-19 code review | candidate | — | — |
|
||||||
| panel-notifications | E-mail notifications about important events | in progress | 6/10 | [plans/panel-notifications.md](plans/panel-notifications.md) |
|
| panel-notifications | E-mail notifications about important events | in progress | 8/10 | [plans/panel-notifications.md](plans/panel-notifications.md) |
|
||||||
| delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | shipped `2.2.0` | 8/8 | [plans/delivery-log-storage.md](plans/delivery-log-storage.md) |
|
| delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | shipped `2.2.0` | 8/8 | [plans/delivery-log-storage.md](plans/delivery-log-storage.md) |
|
||||||
| password-reset | Password reset by e-mail | candidate | — | — |
|
| password-reset | Password reset by e-mail | candidate | — | — |
|
||||||
| phone-desktop-mode | A phone browser in "desktop site" mode gets the phone navigation | candidate | — | — |
|
| phone-desktop-mode | A phone browser in "desktop site" mode gets the phone navigation | candidate | — | — |
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeZone is a DNS server on the loopback that answers from a table, so that a
|
||||||
|
// page can be rendered against records that are published, wrong or absent
|
||||||
|
// without leaving the machine. Names are lower case without the trailing dot;
|
||||||
|
// a name the zone does not hold is NXDOMAIN, which is how a record that is not
|
||||||
|
// published looks to the checker.
|
||||||
|
type fakeZone struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
txt map[string][]string
|
||||||
|
addr map[string]string // name -> IPv4 address
|
||||||
|
}
|
||||||
|
|
||||||
|
// newFakeZone starts the server for the length of the test and returns the
|
||||||
|
// resolver address to give dnscheck.New.
|
||||||
|
func newFakeZone(t *testing.T) (*fakeZone, string) {
|
||||||
|
t.Helper()
|
||||||
|
conn, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fake DNS: listen: %v", err)
|
||||||
|
}
|
||||||
|
z := &fakeZone{txt: map[string][]string{}, addr: map[string]string{}}
|
||||||
|
t.Cleanup(func() { _ = conn.Close() })
|
||||||
|
go func() {
|
||||||
|
buf := make([]byte, 1500)
|
||||||
|
for {
|
||||||
|
n, from, err := conn.ReadFrom(buf)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if resp := z.answer(buf[:n]); resp != nil {
|
||||||
|
_, _ = conn.WriteTo(resp, from)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return z, conn.LocalAddr().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishTXT sets the TXT record of a name; publishA its IPv4 address.
|
||||||
|
func (z *fakeZone) publishTXT(name, value string) {
|
||||||
|
z.mu.Lock()
|
||||||
|
defer z.mu.Unlock()
|
||||||
|
z.txt[strings.ToLower(name)] = append(z.txt[strings.ToLower(name)], value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (z *fakeZone) publishA(name, ip string) {
|
||||||
|
z.mu.Lock()
|
||||||
|
defer z.mu.Unlock()
|
||||||
|
z.addr[strings.ToLower(name)] = ip
|
||||||
|
}
|
||||||
|
|
||||||
|
// answer builds the reply to one query: the question is copied back, then the
|
||||||
|
// records of the asked type if the zone has any. A name with nothing of the
|
||||||
|
// asked type but known under another type is an empty answer (NOERROR), an
|
||||||
|
// unknown name is NXDOMAIN.
|
||||||
|
func (z *fakeZone) answer(q []byte) []byte {
|
||||||
|
if len(q) < 12 || binary.BigEndian.Uint16(q[4:6]) != 1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
i, labels := 12, []string{}
|
||||||
|
for {
|
||||||
|
if i >= len(q) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
l := int(q[i])
|
||||||
|
if l == 0 {
|
||||||
|
i++
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if i+1+l > len(q) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
labels = append(labels, string(q[i+1:i+1+l]))
|
||||||
|
i += 1 + l
|
||||||
|
}
|
||||||
|
if i+4 > len(q) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
name, qtype := strings.ToLower(strings.Join(labels, ".")), binary.BigEndian.Uint16(q[i:i+2])
|
||||||
|
question := q[12 : i+4]
|
||||||
|
|
||||||
|
z.mu.Lock()
|
||||||
|
txt, ip := z.txt[name], z.addr[name]
|
||||||
|
z.mu.Unlock()
|
||||||
|
|
||||||
|
var answers [][]byte
|
||||||
|
switch {
|
||||||
|
case qtype == 16:
|
||||||
|
for _, v := range txt {
|
||||||
|
var rdata []byte
|
||||||
|
for len(v) > 255 {
|
||||||
|
rdata = append(rdata, 255)
|
||||||
|
rdata = append(rdata, v[:255]...)
|
||||||
|
v = v[255:]
|
||||||
|
}
|
||||||
|
rdata = append(rdata, byte(len(v)))
|
||||||
|
rdata = append(rdata, v...)
|
||||||
|
answers = append(answers, record(16, rdata))
|
||||||
|
}
|
||||||
|
case qtype == 1 && ip != "":
|
||||||
|
answers = append(answers, record(1, net.ParseIP(ip).To4()))
|
||||||
|
}
|
||||||
|
rcode := uint16(0)
|
||||||
|
if len(txt) == 0 && ip == "" {
|
||||||
|
rcode = 3
|
||||||
|
}
|
||||||
|
resp := make([]byte, 12, 512)
|
||||||
|
copy(resp, q[:2])
|
||||||
|
binary.BigEndian.PutUint16(resp[2:], 0x8180|rcode)
|
||||||
|
binary.BigEndian.PutUint16(resp[4:], 1)
|
||||||
|
binary.BigEndian.PutUint16(resp[6:], uint16(len(answers)))
|
||||||
|
resp = append(resp, question...)
|
||||||
|
for _, a := range answers {
|
||||||
|
resp = append(resp, a...)
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
// record is one answer record named by the question (a pointer to offset 12),
|
||||||
|
// class IN, with a TTL of a minute.
|
||||||
|
func record(rtype uint16, rdata []byte) []byte {
|
||||||
|
r := []byte{0xC0, 0x0C, 0, 0, 0, 1, 0, 0, 0, 60, 0, 0}
|
||||||
|
binary.BigEndian.PutUint16(r[2:], rtype)
|
||||||
|
binary.BigEndian.PutUint16(r[10:], uint16(len(rdata)))
|
||||||
|
return append(r, rdata...)
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ type accountForm struct {
|
|||||||
Err string
|
Err string
|
||||||
Username string
|
Username string
|
||||||
Email string
|
Email string
|
||||||
|
Notify bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handlers) accountUser(w http.ResponseWriter, r *http.Request) (store.User, bool) {
|
func (h *Handlers) accountUser(w http.ResponseWriter, r *http.Request) (store.User, bool) {
|
||||||
@@ -44,7 +45,7 @@ func (h *Handlers) accountUser(w http.ResponseWriter, r *http.Request) (store.Us
|
|||||||
}
|
}
|
||||||
|
|
||||||
func formOf(u store.User) accountForm {
|
func formOf(u store.User) accountForm {
|
||||||
return accountForm{Username: u.Username, Email: u.Email}
|
return accountForm{Username: u.Username, Email: u.Email, Notify: u.Notify}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleAccount shows the Account page.
|
// HandleAccount shows the Account page.
|
||||||
@@ -58,7 +59,7 @@ func (h *Handlers) HandleAccount(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (h *Handlers) renderAccount(w http.ResponseWriter, r *http.Request, status int, u store.User, f accountForm) {
|
func (h *Handlers) renderAccount(w http.ResponseWriter, r *http.Request, status int, u store.User, f accountForm) {
|
||||||
page := view.NewAccount(h.shellMeta(r), string(u.Role))
|
page := view.NewAccount(h.shellMeta(r), string(u.Role))
|
||||||
page.Username, page.Email = f.Username, f.Email
|
page.Username, page.Email, page.Notify = f.Username, f.Email, f.Notify
|
||||||
page.WithResult(accountFlash(r), f.Err)
|
page.WithResult(accountFlash(r), f.Err)
|
||||||
h.view.Render(w, status, "account", page)
|
h.view.Render(w, status, "account", page)
|
||||||
}
|
}
|
||||||
@@ -90,6 +91,8 @@ func (h *Handlers) HandleAccountProfile(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
f.Username = strings.TrimSpace(r.PostFormValue("username"))
|
f.Username = strings.TrimSpace(r.PostFormValue("username"))
|
||||||
f.Email = strings.TrimSpace(r.PostFormValue("email"))
|
f.Email = strings.TrimSpace(r.PostFormValue("email"))
|
||||||
|
// An unticked checkbox is not sent at all: absent is off.
|
||||||
|
f.Notify = r.PostFormValue("notify") != ""
|
||||||
if f.Username == "" {
|
if f.Username == "" {
|
||||||
f.Username = u.Username
|
f.Username = u.Username
|
||||||
}
|
}
|
||||||
@@ -112,7 +115,14 @@ func (h *Handlers) HandleAccountProfile(w http.ResponseWriter, r *http.Request)
|
|||||||
fail(http.StatusInternalServerError, "Could not save the profile. Please check the logs and try again.")
|
fail(http.StatusInternalServerError, "Could not save the profile. Please check the logs and try again.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
logf("panel: user %d profile updated (username: %t, e-mail: %t)", u.ID, f.Username != u.Username, f.Email != u.Email)
|
if f.Notify != u.Notify {
|
||||||
|
if err := h.store.SetUserNotify(u.ID, f.Notify); err != nil {
|
||||||
|
logf("panel: account: set notifications of user %d: %v", u.ID, err)
|
||||||
|
fail(http.StatusInternalServerError, "Could not save the profile. Please check the logs and try again.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logf("panel: user %d profile updated (username: %t, e-mail: %t, notifications: %t)", u.ID, f.Username != u.Username, f.Email != u.Email, f.Notify)
|
||||||
http.Redirect(w, r, "/account?done=profile", http.StatusSeeOther)
|
http.Redirect(w, r, "/account?done=profile", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,315 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||||
|
"github.com/mixeme/selfpost/internal/domain"
|
||||||
|
"github.com/mixeme/selfpost/internal/health"
|
||||||
|
"github.com/mixeme/selfpost/internal/web/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The panel pages of notifications (plans/panel-notifications.md): the user's
|
||||||
|
// checkbox on Account, the instance's switch and the DNS records of the server's
|
||||||
|
// own name on Server › Settings, and the state of those records on Server ›
|
||||||
|
// Health. What is sent and when is internal/notify's and tested there.
|
||||||
|
|
||||||
|
const notifyHost = "mail.example.org"
|
||||||
|
|
||||||
|
// ---- Account
|
||||||
|
|
||||||
|
// The checkbox under the e-mail is the user's own switch: the page shows what is
|
||||||
|
// stored, Save profile writes it, and an unchecked box is a form without the
|
||||||
|
// field, which means off. Saving it changes nothing else of the profile.
|
||||||
|
func TestAccountNotifyCheckbox(t *testing.T) {
|
||||||
|
h, _ := settingsServer(t)
|
||||||
|
notify := func() bool {
|
||||||
|
u, _ := h.store.GetUser(globalPrincipal.ID)
|
||||||
|
return u.Notify
|
||||||
|
}
|
||||||
|
save := func(extra url.Values) {
|
||||||
|
t.Helper()
|
||||||
|
form := url.Values{"username": {"admin"}, "email": {"mix@example.org"}}
|
||||||
|
for k, v := range extra {
|
||||||
|
form[k] = v
|
||||||
|
}
|
||||||
|
if rec := postFormAs(h.HandleAccountProfile, globalPrincipal, "/account/profile", nil, form); rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("POST /account/profile %v = %d:\n%s", extra, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
save(url.Values{"notify": {"on"}})
|
||||||
|
if !notify() {
|
||||||
|
t.Error("a ticked box did not turn notifications on")
|
||||||
|
}
|
||||||
|
if out := getBody(t, h.HandleAccount, "/account"); !strings.Contains(out, `name="notify" checked`) {
|
||||||
|
t.Errorf("a user who has them on is shown an unticked box:\n%s", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
save(nil)
|
||||||
|
if notify() {
|
||||||
|
t.Error("a form without the field did not turn notifications off")
|
||||||
|
}
|
||||||
|
if out := getBody(t, h.HandleAccount, "/account"); strings.Contains(out, `name="notify" checked`) || !strings.Contains(out, `name="notify"`) {
|
||||||
|
t.Errorf("a user who has them off is shown a ticked box, or none:\n%s", out)
|
||||||
|
}
|
||||||
|
if u, _ := h.store.GetUser(globalPrincipal.ID); u.Email != "mix@example.org" {
|
||||||
|
t.Errorf("saving the box lost the e-mail: %q", u.Email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A profile that is refused saves nothing, the box included, and shows the form
|
||||||
|
// again as it was submitted.
|
||||||
|
func TestAccountNotifyIsNotSavedWithARefusedProfile(t *testing.T) {
|
||||||
|
h, _ := settingsServer(t)
|
||||||
|
if err := h.store.SetUserNotify(globalPrincipal.ID, false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rec := postFormAs(h.HandleAccountProfile, globalPrincipal, "/account/profile", nil,
|
||||||
|
url.Values{"username": {"admin"}, "email": {"not-an-address"}, "notify": {"on"}})
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("POST /account/profile with a bad e-mail = %d, want 400", rec.Code)
|
||||||
|
}
|
||||||
|
if u, _ := h.store.GetUser(globalPrincipal.ID); u.Notify {
|
||||||
|
t.Error("a refused profile turned notifications on")
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), `name="notify" checked`) {
|
||||||
|
t.Error("the form shown again with the error lost the box the user ticked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Server › Settings
|
||||||
|
|
||||||
|
// The switch is saved with the rest of the page and only a global user can save
|
||||||
|
// it: a domain administrator is told the page does not exist, and nothing moves.
|
||||||
|
func TestSettingsNotificationsSwitch(t *testing.T) {
|
||||||
|
h, _ := settingsServer(t)
|
||||||
|
on := func() bool {
|
||||||
|
v, err := h.store.NotificationsEnabled()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
save := func(p auth.Principal, notify bool) *httptest.ResponseRecorder {
|
||||||
|
form := url.Values{"send_log_retention_days": {"60"}}
|
||||||
|
if notify {
|
||||||
|
form.Set("notify", "on")
|
||||||
|
}
|
||||||
|
return postFormAs(h.HandleServerSettings, p, "/server/settings", nil, form)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !on() {
|
||||||
|
t.Fatal("a fresh instance has notifications off")
|
||||||
|
}
|
||||||
|
if rec := save(globalPrincipal, false); rec.Code != http.StatusSeeOther || on() {
|
||||||
|
t.Errorf("an unticked box = %d, switch on: %v; want 303 and off", rec.Code, on())
|
||||||
|
}
|
||||||
|
if out := getBody(t, h.HandleServerSettings, "/server/settings"); strings.Contains(out, `name="notify" checked`) || !strings.Contains(out, `name="notify"`) {
|
||||||
|
t.Errorf("the page shows the switch on while it is off:\n%s", out)
|
||||||
|
}
|
||||||
|
if rec := save(globalPrincipal, true); rec.Code != http.StatusSeeOther || !on() {
|
||||||
|
t.Errorf("a ticked box = %d, switch on: %v; want 303 and on", rec.Code, on())
|
||||||
|
}
|
||||||
|
if out := getBody(t, h.HandleServerSettings, "/server/settings"); !strings.Contains(out, `name="notify" checked`) {
|
||||||
|
t.Errorf("the page shows the switch off while it is on:\n%s", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
d, err := h.store.AddDomain("example.com", "mail")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ops := domainAdmin(t, h.store, "ops", d.ID)
|
||||||
|
if rec := save(ops, false); rec.Code != http.StatusNotFound || !on() {
|
||||||
|
t.Errorf("a domain administrator turning the switch off = %d, switch on: %v; want 404 and on", rec.Code, on())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A page that is refused (here: a retention out of range) leaves the switch as
|
||||||
|
// it was, like every other setting on it.
|
||||||
|
func TestSettingsRefusalLeavesTheNotificationsSwitch(t *testing.T) {
|
||||||
|
h, _ := settingsServer(t)
|
||||||
|
rec := postFormAs(h.HandleServerSettings, globalPrincipal, "/server/settings", nil, url.Values{"send_log_retention_days": {"3"}})
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("POST /server/settings = %d, want 400", rec.Code)
|
||||||
|
}
|
||||||
|
if on, _ := h.store.NotificationsEnabled(); !on {
|
||||||
|
t.Error("a refused page turned notifications off")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// serverMailStand is a panel that knows its own name, has the key notifications
|
||||||
|
// are signed with, and asks a DNS server that answers from a table: the address
|
||||||
|
// of the server's name is there, everything else is for the test to publish.
|
||||||
|
func serverMailStand(t *testing.T) (*Handlers, *fakeZone, domain.ServerMail) {
|
||||||
|
t.Helper()
|
||||||
|
h, _ := settingsServer(t)
|
||||||
|
zone, addr := newFakeZone(t)
|
||||||
|
zone.publishA(notifyHost, "192.0.2.10")
|
||||||
|
h.dns = dnscheck.New([]string{addr})
|
||||||
|
h.cfg.Hostname = notifyHost
|
||||||
|
h.machine = &health.MachineSampler{}
|
||||||
|
h.domains = domain.NewService(h.store, domain.NewOpenDKIM(t.TempDir()), nil, "sp2026")
|
||||||
|
h.domains.SetServerName(notifyHost)
|
||||||
|
mail, err := h.domains.ServerMail()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ServerMail: %v", err)
|
||||||
|
}
|
||||||
|
return h, zone, mail
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishMail puts both records of the server's own name into the zone.
|
||||||
|
func publishMail(zone *fakeZone, mail domain.ServerMail) {
|
||||||
|
zone.publishTXT(mail.Record.Name, mail.Record.Value)
|
||||||
|
zone.publishTXT(notifyHost, dnscheck.ServerMailSPF)
|
||||||
|
}
|
||||||
|
|
||||||
|
// With both records in DNS, Settings gives each with the value to publish and
|
||||||
|
// the state "published", and no warning about what is missing.
|
||||||
|
func TestSettingsMailRecordsPublished(t *testing.T) {
|
||||||
|
h, zone, mail := serverMailStand(t)
|
||||||
|
publishMail(zone, mail)
|
||||||
|
|
||||||
|
out := getBody(t, h.HandleServerSettings, "/server/settings")
|
||||||
|
for _, want := range []string{
|
||||||
|
`id="notification-mail"`, `DNS records of the server's own name`,
|
||||||
|
`<h3>DKIM</h3><span class="tag is-success is-light">published</span>`,
|
||||||
|
`selector <code>sp2026</code>`, `value="sp2026._domainkey.mail.example.org"`,
|
||||||
|
`<h3>SPF</h3><span class="tag is-success is-light">published</span>`,
|
||||||
|
`For the server's own name, not for your domains`,
|
||||||
|
`value="v=spf1 a -all"`,
|
||||||
|
`<span class="sp-mono">postmaster@mail.example.org</span>`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("settings page is missing %q:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "may file them as spam") {
|
||||||
|
t.Error("the warning about a missing SPF record is shown while it is published")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With nothing published the records are the same and the states say so, as a
|
||||||
|
// warning and not as the red of a domain: notifications are sent without them.
|
||||||
|
// The page says what that costs under SPF and what is missing under DKIM.
|
||||||
|
func TestSettingsMailRecordsMissing(t *testing.T) {
|
||||||
|
h, _, _ := serverMailStand(t)
|
||||||
|
|
||||||
|
out := getBody(t, h.HandleServerSettings, "/server/settings")
|
||||||
|
for _, want := range []string{
|
||||||
|
`<h3>DKIM</h3><span class="tag is-warning is-light">missing</span>`,
|
||||||
|
`<h3>SPF</h3><span class="tag is-warning is-light">missing</span>`,
|
||||||
|
`value="sp2026._domainkey.mail.example.org"`, `value="v=spf1 a -all"`,
|
||||||
|
`Notifications are sent without it, and a receiver may file them as spam.`,
|
||||||
|
`No TXT record is published at sp2026._domainkey.mail.example.org.`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("settings page is missing %q:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(out, `is-danger is-light">missing`) {
|
||||||
|
t.Error("a record of the server's own name that is missing is drawn as an error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A DKIM record that holds some other key is a mismatch, and the page shows what
|
||||||
|
// DNS holds next to what to publish.
|
||||||
|
func TestSettingsMailDKIMWithAnotherKey(t *testing.T) {
|
||||||
|
h, zone, mail := serverMailStand(t)
|
||||||
|
zone.publishTXT(mail.Record.Name, "v=DKIM1; k=rsa; p=AAAAB3NzaC1yc2EAAAADAQABAAABAQDhWrongKeyEntirely")
|
||||||
|
|
||||||
|
out := getBody(t, h.HandleServerSettings, "/server/settings")
|
||||||
|
for _, want := range []string{`<span class="tag is-warning is-light">mismatch</span>`, `In DNS now`, `WrongKeyEntirely`} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("settings page is missing %q:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the key of the server's own name cannot be read the page is still there:
|
||||||
|
// the DKIM record is left out and SPF is shown unchecked.
|
||||||
|
func TestSettingsWithoutAServerKeyStillRenders(t *testing.T) {
|
||||||
|
h, _ := settingsServer(t) // its domain service does not know the server's name
|
||||||
|
out := getBody(t, h.HandleServerSettings, "/server/settings")
|
||||||
|
for _, want := range []string{`id="notification-mail"`, `<h3>SPF</h3><span class="tag is-light">not checked</span>`, `name="notify"`} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("settings page is missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "<h3>DKIM</h3>") {
|
||||||
|
t.Error("a DKIM record is shown for a key that could not be read")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Server › Health
|
||||||
|
|
||||||
|
// Health has the card with the state of each record, the host under its name,
|
||||||
|
// and the way to the values on Settings. A record that is missing makes the
|
||||||
|
// card a warning.
|
||||||
|
func TestHealthNotificationMailCard(t *testing.T) {
|
||||||
|
h, zone, mail := serverMailStand(t)
|
||||||
|
zone.publishTXT(mail.Record.Name, mail.Record.Value) // DKIM only
|
||||||
|
|
||||||
|
out := getBody(t, h.HandleHealth, "/server/health")
|
||||||
|
for _, want := range []string{
|
||||||
|
`id="notification-mail"`, `<h2>Notification mail</h2><div class="sp-end"><span class="tag is-warning is-light">warn</span></div>`,
|
||||||
|
`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"`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("health page is missing %q:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With both records in place the card is green. The card is part of the polled
|
||||||
|
// fragment, swapped in by its id like the second row.
|
||||||
|
func TestHealthNotificationMailCardIsGreenAndPolled(t *testing.T) {
|
||||||
|
h, zone, mail := serverMailStand(t)
|
||||||
|
publishMail(zone, mail)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest("GET", "/server/health/fragment", nil)
|
||||||
|
h.HandleHealthFragment(rec, auth.RequestWithPrincipal(req, globalPrincipal))
|
||||||
|
out := rec.Body.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
`id="health-mail" hx-swap-oob="true"`,
|
||||||
|
`<h2>Notification mail</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div>`,
|
||||||
|
`<td><span class="tag is-success is-light">published</span></td>`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("health fragment is missing %q:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(out[strings.Index(out, `id="health-mail"`):], "is-warning") {
|
||||||
|
t.Error("the card warns while both records are published")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The DNS answers are cached for a few minutes; Re-check DNS on Health asks for
|
||||||
|
// the records of the server's own name again as well as for its address.
|
||||||
|
func TestHealthRecheckAsksForNotificationMailAgain(t *testing.T) {
|
||||||
|
h, zone, mail := serverMailStand(t)
|
||||||
|
if out := getBody(t, h.HandleHealth, "/server/health"); strings.Count(out, `is-warning is-light">missing`) != 2 {
|
||||||
|
t.Fatalf("before publishing, both records should be missing:\n%s", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
publishMail(zone, mail)
|
||||||
|
if out := getBody(t, h.HandleHealth, "/server/health"); strings.Contains(out, `is-success is-light">published`) {
|
||||||
|
t.Fatal("the cached answer was not used: the test cannot tell a re-check from a fresh read")
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := postFormAs(h.HandleHealthRecheck, globalPrincipal, "/server/health/recheck", nil, url.Values{})
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("POST /server/health/recheck = %d, want 303", rec.Code)
|
||||||
|
}
|
||||||
|
if out := getBody(t, h.HandleHealth, "/server/health"); strings.Count(out, `is-success is-light">published`) != 2 {
|
||||||
|
t.Errorf("after Re-check DNS both records should be published:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,13 +5,18 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||||
|
"github.com/mixeme/selfpost/internal/health"
|
||||||
|
"github.com/mixeme/selfpost/internal/notify"
|
||||||
"github.com/mixeme/selfpost/internal/store"
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
"github.com/mixeme/selfpost/internal/web/view"
|
"github.com/mixeme/selfpost/internal/web/view"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Server › Settings holds what is true of the whole instance, whoever is
|
// Server › Settings holds what is true of the whole instance, whoever is
|
||||||
// signed in: log retention (the outbound log's, and the inbound log's while the
|
// signed in: log retention (the outbound log's, and the inbound log's while the
|
||||||
// inbound relay is on) and the read-only view of the rate limits. A
|
// inbound relay is on), the read-only view of the rate limits, the switch for
|
||||||
|
// notification e-mails and the DNS records of the server's own name those are
|
||||||
|
// sent from. A
|
||||||
// user's own name, e-mail, password and DMARC default are on Account
|
// user's own name, e-mail, password and DMARC default are on Account
|
||||||
// (handlers_account.go). Before placing a setting here, ask whose it is.
|
// (handlers_account.go). Before placing a setting here, ask whose it is.
|
||||||
|
|
||||||
@@ -47,6 +52,12 @@ func (h *Handlers) currentRetentions() retentions {
|
|||||||
|
|
||||||
func (h *Handlers) renderServerSettings(w http.ResponseWriter, r *http.Request, status int, formErr string, form retentions) {
|
func (h *Handlers) renderServerSettings(w http.ResponseWriter, r *http.Request, status int, formErr string, form retentions) {
|
||||||
page := view.NewSettings(h.shellMeta(r), strconv.Itoa(form.outbound), view.FormatRate(h.l1Messages(), h.l1Window()))
|
page := view.NewSettings(h.shellMeta(r), strconv.Itoa(form.outbound), view.FormatRate(h.l1Messages(), h.l1Window()))
|
||||||
|
on, err := h.store.NotificationsEnabled()
|
||||||
|
if err != nil {
|
||||||
|
logf("panel: settings: read the notifications switch: %v", err)
|
||||||
|
}
|
||||||
|
page.WithNotifications(on, notify.From(h.cfg.Hostname))
|
||||||
|
page.WithMail(h.serverMail(false).records)
|
||||||
// The inbound field exists while there is an inbound journal to keep.
|
// The inbound field exists while there is an inbound journal to keep.
|
||||||
if h.cfg.InboundEnabled {
|
if h.cfg.InboundEnabled {
|
||||||
page.WithInbound(strconv.Itoa(form.inbound))
|
page.WithInbound(strconv.Itoa(form.inbound))
|
||||||
@@ -87,12 +98,20 @@ func (h *Handlers) submitServerSettings(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// An unticked checkbox is not sent at all: absent is off.
|
||||||
|
notifications := r.PostFormValue("notify") != ""
|
||||||
if err := h.store.SetSendLogRetentionDays(days); err != nil {
|
if err := h.store.SetSendLogRetentionDays(days); err != nil {
|
||||||
logf("panel: settings: set send-log retention failed: %v", err)
|
logf("panel: settings: set send-log retention failed: %v", err)
|
||||||
h.renderServerSettings(w, r, http.StatusInternalServerError,
|
h.renderServerSettings(w, r, http.StatusInternalServerError,
|
||||||
"Could not save send log retention. Please check the logs and try again.", form)
|
"Could not save send log retention. Please check the logs and try again.", form)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := h.store.SetNotificationsEnabled(notifications); err != nil {
|
||||||
|
logf("panel: settings: set the notifications switch failed: %v", err)
|
||||||
|
h.renderServerSettings(w, r, http.StatusInternalServerError,
|
||||||
|
"Could not save the notifications switch. Please check the logs and try again.", form)
|
||||||
|
return
|
||||||
|
}
|
||||||
if h.cfg.InboundEnabled {
|
if h.cfg.InboundEnabled {
|
||||||
if err := h.store.SetInboundSpamLogRetentionDays(form.inbound); err != nil {
|
if err := h.store.SetInboundSpamLogRetentionDays(form.inbound); err != nil {
|
||||||
logf("panel: settings: set inbound-log retention failed: %v", err)
|
logf("panel: settings: set inbound-log retention failed: %v", err)
|
||||||
@@ -100,9 +119,9 @@ func (h *Handlers) submitServerSettings(w http.ResponseWriter, r *http.Request)
|
|||||||
"Could not save inbound log retention. Please check the logs and try again.", form)
|
"Could not save inbound log retention. Please check the logs and try again.", form)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
logf("panel: instance settings updated (send-log retention: %d days, inbound-log retention: %d days)", days, form.inbound)
|
logf("panel: instance settings updated (send-log retention: %d days, inbound-log retention: %d days, notifications: %t)", days, form.inbound, notifications)
|
||||||
} else {
|
} else {
|
||||||
logf("panel: instance settings updated (send-log retention: %d days)", days)
|
logf("panel: instance settings updated (send-log retention: %d days, notifications: %t)", days, notifications)
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, "/server/settings?saved=1", http.StatusSeeOther)
|
http.Redirect(w, r, "/server/settings?saved=1", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
@@ -119,3 +138,48 @@ func (h *Handlers) sendLogRetentionDays() int {
|
|||||||
}
|
}
|
||||||
return days
|
return days
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// serverMailState is the server's own name as a sender of notifications: the
|
||||||
|
// records to publish for it, as Settings draws them, and their state, as
|
||||||
|
// Health lists it.
|
||||||
|
type serverMailState struct {
|
||||||
|
records []view.Record
|
||||||
|
rows []view.MailRow
|
||||||
|
// status is the verdict of Health's card: ok, warn or unknown. A record
|
||||||
|
// that is not in place is a warning, never an error: without them the
|
||||||
|
// notifications are still sent.
|
||||||
|
status string
|
||||||
|
}
|
||||||
|
|
||||||
|
// serverMail reads the records of the server's own name and checks them in DNS
|
||||||
|
// (cached like a domain's; force skips the cache, for Re-check DNS). When the
|
||||||
|
// DKIM key cannot be read the page is still shown: the DKIM record is left out,
|
||||||
|
// the SPF record is shown unchecked, and the card does not claim it is fine.
|
||||||
|
func (h *Handlers) serverMail(force bool) serverMailState {
|
||||||
|
host := h.cfg.Hostname
|
||||||
|
spfOf := func(c view.DNSCheck) (view.Record, view.MailRow) {
|
||||||
|
return view.ServerMailSPF(host, dnscheck.ServerMailSPF, c), view.NewMailRow("SPF", host, c)
|
||||||
|
}
|
||||||
|
mail, err := h.domains.ServerMail()
|
||||||
|
if err != nil {
|
||||||
|
logf("panel: server mail: %v", err)
|
||||||
|
rec, row := spfOf(view.DNSCheck{Status: string(health.StatusUnknown)})
|
||||||
|
return serverMailState{records: []view.Record{rec}, rows: []view.MailRow{row}, status: string(health.StatusUnknown)}
|
||||||
|
}
|
||||||
|
srv := h.dns.Server(host, false)
|
||||||
|
check := h.dns.ServerMail(dnscheck.Query{
|
||||||
|
Name: host, Selector: mail.Selector, ExpectedDKIM: mail.Record.Value,
|
||||||
|
Hostname: srv.Hostname, ServerIPs: srv.IPs,
|
||||||
|
}, force)
|
||||||
|
dkim := dnsCheck(check.DKIM)
|
||||||
|
spfRec, spfRow := spfOf(dnsCheck(check.SPF))
|
||||||
|
status := check.Overall
|
||||||
|
if status == health.StatusError {
|
||||||
|
status = health.StatusWarn
|
||||||
|
}
|
||||||
|
return serverMailState{
|
||||||
|
records: []view.Record{view.ServerMailDKIM(mail.Selector, mail.Record.Name, mail.Record.Value, dkim), spfRec},
|
||||||
|
rows: []view.MailRow{view.NewMailRow("DKIM", mail.Record.Name, dkim), spfRow},
|
||||||
|
status: string(status),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||||
|
"github.com/mixeme/selfpost/internal/domain"
|
||||||
"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"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
@@ -120,6 +121,10 @@ func settingsServerAt(t *testing.T, dbPath string) (*Handlers, string) {
|
|||||||
view: v,
|
view: v,
|
||||||
auth: a,
|
auth: a,
|
||||||
dns: dnscheck.New(nil),
|
dns: dnscheck.New(nil),
|
||||||
cfg: Config{SendLogRetentionEnvDefault: 90},
|
// A domain service that does not know the server's own name, so that
|
||||||
|
// the records of Settings and Health come out as "cannot be read";
|
||||||
|
// the tests of those records use serverMailStand.
|
||||||
|
domains: domain.NewService(st, domain.NewOpenDKIM(t.TempDir()), nil, "selfpost"),
|
||||||
|
cfg: Config{SendLogRetentionEnvDefault: 90},
|
||||||
}, password
|
}, password
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||||
"github.com/mixeme/selfpost/internal/health"
|
"github.com/mixeme/selfpost/internal/health"
|
||||||
|
"github.com/mixeme/selfpost/internal/notify"
|
||||||
"github.com/mixeme/selfpost/internal/store"
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
"github.com/mixeme/selfpost/internal/web/view"
|
"github.com/mixeme/selfpost/internal/web/view"
|
||||||
)
|
)
|
||||||
@@ -114,7 +115,7 @@ func (h *Handlers) HandleHealth(w http.ResponseWriter, r *http.Request) {
|
|||||||
if _, ok := h.requireGlobal(w, r); !ok {
|
if _, ok := h.requireGlobal(w, r); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.view.Render(w, http.StatusOK, "health", healthPage(h.shellMeta(r), h.healthChecks(), statusFlash(r)))
|
h.view.Render(w, http.StatusOK, "health", h.healthView(r, statusFlash(r)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleHealthFragment is the polled part of Health: the same two rows of
|
// HandleHealthFragment is the polled part of Health: the same two rows of
|
||||||
@@ -123,7 +124,7 @@ func (h *Handlers) HandleHealthFragment(w http.ResponseWriter, r *http.Request)
|
|||||||
if _, ok := h.requireGlobal(w, r); !ok {
|
if _, ok := h.requireGlobal(w, r); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
page := healthPage(h.shellMeta(r), h.healthChecks(), "")
|
page := h.healthView(r, "")
|
||||||
page.Refresh = true
|
page.Refresh = true
|
||||||
h.view.RenderFragment(w, http.StatusOK, "health_body", page)
|
h.view.RenderFragment(w, http.StatusOK, "health_body", page)
|
||||||
}
|
}
|
||||||
@@ -134,9 +135,18 @@ func (h *Handlers) HandleHealthRecheck(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.dns.Server(h.cfg.Hostname, true)
|
h.dns.Server(h.cfg.Hostname, true)
|
||||||
|
h.serverMail(true)
|
||||||
http.Redirect(w, r, "/server/health?rechecked=1", http.StatusSeeOther)
|
http.Redirect(w, r, "/server/health?rechecked=1", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// healthView is the Health page for the request: the checks behind the cards
|
||||||
|
// of Overview and the Notification mail card, which only Health shows.
|
||||||
|
func (h *Handlers) healthView(r *http.Request, flash string) *view.Health {
|
||||||
|
page := healthPage(h.shellMeta(r), h.healthChecks(), flash)
|
||||||
|
mail := h.serverMail(false)
|
||||||
|
return page.WithNotificationMail(notify.From(h.cfg.Hostname), mail.rows, mail.status)
|
||||||
|
}
|
||||||
|
|
||||||
// healthPage turns one reading of the checks (healthChecks) into the Health
|
// healthPage turns one reading of the checks (healthChecks) into the Health
|
||||||
// page: a table row per resource, process and socket, and the verdict of each
|
// page: a table row per resource, process and socket, and the verdict of each
|
||||||
// box in its head.
|
// box in its head.
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ type Account struct {
|
|||||||
Profile Box
|
Profile Box
|
||||||
Username string
|
Username string
|
||||||
Email string
|
Email string
|
||||||
|
// Notify is whether the panel writes to this user: the checkbox under the
|
||||||
|
// e-mail.
|
||||||
|
Notify bool
|
||||||
|
|
||||||
Password Box
|
Password Box
|
||||||
}
|
}
|
||||||
@@ -63,6 +66,18 @@ type Settings struct {
|
|||||||
|
|
||||||
RateLimits Box
|
RateLimits Box
|
||||||
RateFacts []Fact
|
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
|
// 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",
|
{Label: "Level 2 · domain, application",
|
||||||
Value: Rich("Set on each ", Link("/outbound/domains", "domain"), " and on each application of a domain.")},
|
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.
|
// 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
|
// 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.
|
// only while the inbound relay is on: with it off there is no journal to keep.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package view
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -119,6 +120,10 @@ func healthFixture(refresh bool) *Health {
|
|||||||
{Label: "Hostname", Value: Plain("mail.example.org"), Mono: true},
|
{Label: "Hostname", Value: Plain("mail.example.org"), Mono: true},
|
||||||
{Label: "Lookup", Value: Rich("→ 203.0.113.25", Br(), "← 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
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,14 +131,25 @@ func healthFixture(refresh bool) *Health {
|
|||||||
// and e-mail.
|
// and e-mail.
|
||||||
func accountFixture() *Account {
|
func accountFixture() *Account {
|
||||||
a := NewAccount(admin(), "global")
|
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
|
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
|
// 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 {
|
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
|
// outDomainsFixture is the mockup's Outbound domains: three domains, the first
|
||||||
|
|||||||
@@ -30,10 +30,19 @@ type Health struct {
|
|||||||
CertDetail string
|
CertDetail string
|
||||||
CertProblem bool
|
CertProblem bool
|
||||||
|
|
||||||
Sockets Box
|
Sockets Box
|
||||||
SocketRows []SocketRow
|
SocketRows []SocketRow
|
||||||
Hostname Box
|
Hostname Box
|
||||||
HostFacts []Fact
|
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
|
Configuration Box
|
||||||
Reload Action
|
Reload Action
|
||||||
}
|
}
|
||||||
@@ -87,6 +96,20 @@ type SocketRow struct {
|
|||||||
Detail string
|
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;
|
// 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.
|
// the boxes, the head and the reload action are the page's own.
|
||||||
func NewHealth(m Meta, flash string) *Health {
|
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")},
|
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"},
|
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")},
|
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")},
|
End: Plain("Safe to run at any time")},
|
||||||
Reload: Action{Label: "Reload configuration", Icon: "ti-reload", Post: "/server/health/reload"},
|
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
|
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.
|
// 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, "")} }
|
func Verdict(status string) Text { return Text{TagOf(status, "")} }
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ const (
|
|||||||
HelpBackup = "backup"
|
HelpBackup = "backup"
|
||||||
HelpUsers = "users"
|
HelpUsers = "users"
|
||||||
HelpSettings = "settings"
|
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
|
// Account
|
||||||
HelpAccount = "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: "Backup and moving a domain", Icon: "ti-archive", Href: "#" + HelpBackup},
|
||||||
{Label: "Users and roles", Icon: "ti-users", Href: "#" + HelpUsers},
|
{Label: "Users and roles", Icon: "ti-users", Href: "#" + HelpUsers},
|
||||||
{Label: "Settings", Icon: "ti-settings", Href: "#" + HelpSettings},
|
{Label: "Settings", Icon: "ti-settings", Href: "#" + HelpSettings},
|
||||||
|
{Label: "Notifications", Icon: "ti-mail", Href: "#" + HelpNotifications},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
add("Account", accountTopics, []MenuItem{
|
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 ",
|
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"),
|
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 ",
|
", 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{
|
var accountTopics = []HelpTopic{
|
||||||
{ID: HelpAccount, Title: "Your account", Body: []Text{
|
{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
|
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
|
// 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,
|
// 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
|
// 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
|
// the two actions: Re-check DNS in the head, Reload configuration under the tables
|
||||||
`action="/server/health/recheck"`, `action="/server/health/reload"`,
|
`action="/server/health/recheck"`, `action="/server/health/reload"`,
|
||||||
// the anchors the Overview cards lead to
|
// 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)
|
// 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"`,
|
`<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`,
|
`↓ 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"`,
|
`hx-get="/server/health/fragment"`,
|
||||||
)
|
)
|
||||||
pageLacks(t, "Health", out, `hx-swap-oob`, `/overview/fragment`)
|
pageLacks(t, "Health", out, `hx-swap-oob`, `/overview/fragment`)
|
||||||
if got := strings.Count(out, `class="columns"`); got != 2 {
|
if got := strings.Count(out, `class="columns"`); got != 3 {
|
||||||
t.Errorf("Health has %d rows of boxes, want 2", got)
|
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.
|
// 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"`) ||
|
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 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) {
|
func TestHealthFragmentIsThePolledRows(t *testing.T) {
|
||||||
page := renderSignedIn(t, "health", healthFixture(false))
|
page := renderSignedIn(t, "health", healthFixture(false))
|
||||||
frag := renderFragment(t, "health_body", healthFixture(true))
|
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 {
|
rows := func(s string) string {
|
||||||
a := strings.Index(s, `<div class="columns" id="health-machine"`)
|
return s[strings.Index(s, `<div class="columns" id="health-machine"`):strings.Index(s, card)]
|
||||||
b := strings.Index(s, `id="configuration"`)
|
|
||||||
if b < 0 {
|
|
||||||
b = len(s)
|
|
||||||
}
|
|
||||||
return s[a:b]
|
|
||||||
}
|
}
|
||||||
// Same markup, apart from the out-of-band mark and what follows the rows on the page.
|
mail := func(s string) string {
|
||||||
got := strings.Replace(strings.TrimSpace(frag), ` hx-swap-oob="true"`, "", 1)
|
a := strings.Index(s, card)
|
||||||
want := strings.TrimSpace(rows(page))
|
if b := strings.Index(s, `id="configuration"`); b > a {
|
||||||
if !strings.HasPrefix(want, got) {
|
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)
|
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"`)
|
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 machine whose counters could not be read keeps its rows: the figure becomes
|
||||||
// a dash and the reason stays in the detail column.
|
// a dash and the reason stays in the detail column.
|
||||||
func TestHealthMachineWithoutReadings(t *testing.T) {
|
func TestHealthMachineWithoutReadings(t *testing.T) {
|
||||||
@@ -289,15 +314,17 @@ func TestAccountEscapesTheFormValues(t *testing.T) {
|
|||||||
pageLacks(t, "Account", out, `<script>1`, `"onfocus="x`)
|
pageLacks(t, "Account", out, `<script>1`, `"onfocus="x`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event notifications are not built (roadmap: panel-notifications), and a page
|
// The checkbox under the e-mail is the user's own switch for notifications: it
|
||||||
// carries no box for a feature that does not exist.
|
// shows what is stored, and an unchecked box is the absence of the attribute.
|
||||||
func TestAccountAndSettingsHaveNoNotificationsBox(t *testing.T) {
|
func TestAccountShowsTheNotifyCheckbox(t *testing.T) {
|
||||||
for name, out := range map[string]string{
|
a := accountFixture()
|
||||||
"Account": renderSignedIn(t, "account", accountFixture()),
|
out := renderSignedIn(t, "account", a)
|
||||||
"Settings": renderSignedIn(t, "settings", settingsFixture()),
|
pageHas(t, "Account", out, `<input type="checkbox" name="notify" checked> Send me notifications`, `href="/help#notifications"`)
|
||||||
} {
|
|
||||||
pageLacks(t, name, out, `<h2>Notifications</h2>`, `type="checkbox"`, `/account/notifications`, `name="notify"`)
|
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
|
// ---- 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"`)
|
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) {
|
func TestSettingsShowsTheRefusalAndKeepsWhatWasTyped(t *testing.T) {
|
||||||
s := NewSettings(admin(), "3", FormatRate(600, 3600)).WithResult("", "Send log retention must be between 7 and 365.")
|
s := NewSettings(admin(), "3", FormatRate(600, 3600)).WithResult("", "Send log retention must be between 7 and 365.")
|
||||||
out := renderSignedIn(t, "settings", s)
|
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="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>
|
<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>
|
<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>
|
<button type="submit" class="button is-primary">Save profile</button>
|
||||||
</form>
|
</form>
|
||||||
{{template "body_close"}}
|
{{template "body_close"}}
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
{{/* Server > Health (mockup health.html): every check behind the cards of
|
{{/* Server > Health (mockup health.html): every check behind the cards of
|
||||||
Overview, and the one action that is not about DNS, Reload configuration. The
|
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
|
two rows of tables are the polled part (health_body.html), and so is the
|
||||||
Configuration box are static. */}}
|
Notification mail card (health_mail), which sits in the last row beside the
|
||||||
|
static Configuration box. */}}
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
{{template "page_head" .Head}}
|
{{template "page_head" .Head}}
|
||||||
{{if .Flash}}{{template "flash" .Flash}}{{end}}
|
{{if .Flash}}{{template "flash" .Flash}}{{end}}
|
||||||
|
|
||||||
{{template "health_body" .}}
|
{{template "health_body" .}}
|
||||||
|
|
||||||
{{template "box_open" .Configuration}}
|
<div class="columns">
|
||||||
{{template "body_open"}}
|
{{template "health_mail" .}}
|
||||||
<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>
|
<div class="column is-6">
|
||||||
{{template "action" .Reload}}
|
{{template "box_open" .Configuration}}
|
||||||
{{template "body_close"}}
|
{{template "body_open"}}
|
||||||
{{template "box_close"}}
|
<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}}
|
{{end}}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
fragment (GET /server/health/fragment) alike. panel.js polls the first row
|
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
|
(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
|
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">
|
{{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">
|
<div class="column is-6">
|
||||||
{{template "box_open" .Machine}}
|
{{template "box_open" .Machine}}
|
||||||
@@ -58,4 +60,21 @@
|
|||||||
{{template "body_close"}}
|
{{template "body_close"}}
|
||||||
{{template "box_close"}}
|
{{template "box_close"}}
|
||||||
</div>
|
</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}}
|
</div>{{end}}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
{{/* Server > Settings (mockup settings.html): what holds for the whole instance.
|
{{/* 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
|
One form, one Save button; the rate limits are read-only facts, and the
|
||||||
name, e-mail, password and DMARC address are on Account (account.html). */}}
|
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"}}
|
{{define "content"}}
|
||||||
{{template "page_head" .Head}}
|
{{template "page_head" .Head}}
|
||||||
{{if .Flash}}{{template "flash" .Flash}}{{end}}
|
{{if .Flash}}{{template "flash" .Flash}}{{end}}
|
||||||
@@ -19,15 +21,26 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
{{template "body_close"}}
|
{{template "body_close"}}
|
||||||
{{template "box_close"}}
|
{{template "box_close"}}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="column is-6">
|
|
||||||
{{template "box_open" .RateLimits}}
|
{{template "box_open" .RateLimits}}
|
||||||
{{template "body_open"}}
|
{{template "body_open"}}
|
||||||
{{template "facts" .RateFacts}}
|
{{template "facts" .RateFacts}}
|
||||||
{{template "body_close"}}
|
{{template "body_close"}}
|
||||||
{{template "box_close"}}
|
{{template "box_close"}}
|
||||||
</div>
|
</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>
|
||||||
<div class="buttons"><button type="submit" class="button is-primary">Save settings</button></div>
|
<div class="buttons"><button type="submit" class="button is-primary">Save settings</button></div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -53,6 +53,16 @@ func testDeliveryLogSurvivesRotation(t *testing.T, sc *scenario) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The rotation ends with `postfix reload`, which fails while Postfix is
|
||||||
|
// still starting - and this step follows one that restarts the container.
|
||||||
|
if err := waitFor("Postfix to be running", 30*time.Second, 500*time.Millisecond, func() (bool, error) {
|
||||||
|
if _, err := h.execIn("selfpost", "postfix", "status"); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
if _, err := h.execIn("selfpost", "logrotate", "-f", "/etc/logrotate.d/mail"); err != nil {
|
if _, err := h.execIn("selfpost", "logrotate", "-f", "/etc/logrotate.d/mail"); err != nil {
|
||||||
t.Fatalf("logrotate -f: %v", err)
|
t.Fatalf("logrotate -f: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -263,9 +263,15 @@ func TestE2E(t *testing.T) {
|
|||||||
sc.deliveryPage = page
|
sc.deliveryPage = page
|
||||||
})
|
})
|
||||||
|
|
||||||
|
run("notifications_set_up", func(t *testing.T) {
|
||||||
|
testNotificationsSetUp(t, sc)
|
||||||
|
})
|
||||||
run("negative_level2_ratelimit_via_panel", func(t *testing.T) {
|
run("negative_level2_ratelimit_via_panel", func(t *testing.T) {
|
||||||
testLevel2RateLimit(t, sc)
|
testLevel2RateLimit(t, sc)
|
||||||
})
|
})
|
||||||
|
run("rate_limit_refusal_is_notified", func(t *testing.T) {
|
||||||
|
testRateLimitRefusalIsNotified(t, sc)
|
||||||
|
})
|
||||||
run("negative_sender_login_mismatch", func(t *testing.T) {
|
run("negative_sender_login_mismatch", func(t *testing.T) {
|
||||||
testSenderLoginMismatch(t, sc)
|
testSenderLoginMismatch(t, sc)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The server's own name in the run, and where the administrator is written to:
|
||||||
|
// a mailbox behind the sink-MX, so a notification can be read back.
|
||||||
|
const (
|
||||||
|
serverName = "mail.e2e.test"
|
||||||
|
adminEmail = "admin@sink.e2e.test"
|
||||||
|
)
|
||||||
|
|
||||||
|
// serverMailRecord scrapes the DKIM record of the server's own name off
|
||||||
|
// Server › Settings, where the panel tells the administrator to publish it.
|
||||||
|
func (c *panelClient) serverMailRecord() (name, value string, err error) {
|
||||||
|
_, body, err := c.get("/server/settings")
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
name, ok := extractCodeRow(body, "Host / name")
|
||||||
|
if !ok {
|
||||||
|
return "", "", fmt.Errorf("could not find the server's DKIM record name on Settings")
|
||||||
|
}
|
||||||
|
value, ok = extractCodeRow(body, "Value to publish")
|
||||||
|
if !ok {
|
||||||
|
return "", "", fmt.Errorf("could not find the server's DKIM record value on Settings")
|
||||||
|
}
|
||||||
|
return name, value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setAccountEmail saves the signed-in user's profile with an e-mail and
|
||||||
|
// notifications on.
|
||||||
|
func (c *panelClient) setAccountEmail(username, email string) error {
|
||||||
|
resp, body, err := c.postForm("/account/profile", url.Values{
|
||||||
|
"username": {username},
|
||||||
|
"email": {email},
|
||||||
|
"notify": {"on"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return fmt.Errorf("save profile: status %d: %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
_, page, err := c.get("/account")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !strings.Contains(page, email) {
|
||||||
|
return fmt.Errorf("the Account page does not show %s after saving it", email)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// testNotificationsSetUp gives the administrator a mailbox and publishes the
|
||||||
|
// DKIM record of the server's own name, as an operator would after reading
|
||||||
|
// Server › Settings. It runs before the step that makes a rate limit refuse
|
||||||
|
// mail, so that the refusal finds someone to tell.
|
||||||
|
func testNotificationsSetUp(t *testing.T, sc *scenario) {
|
||||||
|
if err := sc.panel.setAccountEmail(adminUser, adminEmail); err != nil {
|
||||||
|
t.Fatalf("set the administrator's e-mail: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
name, value, err := sc.panel.serverMailRecord()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(name, "._domainkey."+serverName) {
|
||||||
|
t.Fatalf("the record on Settings is %q, want a DKIM key of %s", name, serverName)
|
||||||
|
}
|
||||||
|
rel, ok := trimZoneSuffix(name)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("record name %q is not under e2e.test", name)
|
||||||
|
}
|
||||||
|
records, err := publishTXT(h.stageDir, sc.zoneRecords, txtRecord{name: rel, value: value})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("publish the server's dkim record to the fake zone: %v", err)
|
||||||
|
}
|
||||||
|
sc.zoneRecords = records
|
||||||
|
}
|
||||||
|
|
||||||
|
// testRateLimitRefusalIsNotified checks the whole path of a notification: the
|
||||||
|
// level-2 refusal of the previous step is noticed by the notifier (it looks
|
||||||
|
// every minute), written to the administrator, submitted by the unprivileged
|
||||||
|
// panel through sendmail, signed by OpenDKIM with the key of the server's own
|
||||||
|
// name and delivered. It also holds what a notification is not: a row of the
|
||||||
|
// Outbound log. That it is said once is for the unit tests to hold
|
||||||
|
// (internal/notify): here it would be a minute of waiting for nothing to happen.
|
||||||
|
func testRateLimitRefusalIsNotified(t *testing.T, sc *scenario) {
|
||||||
|
subject := "[SelfPost] " + senderDomain + ": a rate limit is refusing mail"
|
||||||
|
raw, err := findSinkMessage(h.stageDir, subject, 100*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("no notification reached %s: %v\npanel log:\n%s", adminEmail, err, tailLines(h.logs("selfpost"), "notify", 20))
|
||||||
|
}
|
||||||
|
msg := string(raw)
|
||||||
|
for _, want := range []string{
|
||||||
|
"From: SelfPost <postmaster@" + serverName + ">",
|
||||||
|
"To: <" + adminEmail + ">",
|
||||||
|
"Auto-Submitted: auto-generated",
|
||||||
|
"refused",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(msg, want) {
|
||||||
|
t.Errorf("the notification lacks %q:\n%s", want, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := verifyDKIM(raw, serverName); err != nil {
|
||||||
|
t.Errorf("the notification is not signed as %s with the key Settings shows: %v", serverName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The panel's own mail is not an application's: no row in the log.
|
||||||
|
rows, err := sc.panel.sendLogRows(senderDomain, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read the send log: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(rows, "rate limit is refusing") || strings.Contains(rows, "postmaster@"+serverName) {
|
||||||
|
t.Errorf("a notification is listed in the Outbound log:\n%s", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tailLines returns the last n lines of log that contain filter.
|
||||||
|
func tailLines(log, filter string, n int) string {
|
||||||
|
var keep []string
|
||||||
|
for _, line := range strings.Split(log, "\n") {
|
||||||
|
if strings.Contains(line, filter) {
|
||||||
|
keep = append(keep, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(keep) > n {
|
||||||
|
keep = keep[len(keep)-n:]
|
||||||
|
}
|
||||||
|
return strings.Join(keep, "\n")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user