panel-notifications: Account, Server > Settings, Server > Health, help; e2e
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:
mix
2026-10-12 16:21:48 +03:00
co-authored by Claude Opus 5.5
parent c9027381e5
commit b905f3e560
22 changed files with 1003 additions and 59 deletions
+6
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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 | — | — |
+134
View File
@@ -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...)
}
+13 -3
View File
@@ -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&#39;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&#39;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)
}
}
+67 -3
View File
@@ -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),
// 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}, cfg: Config{SendLogRetentionEnvDefault: 90},
}, password }, password
} }
+12 -2
View File
@@ -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.
+34
View File
@@ -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.
+19 -3
View File
@@ -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
+37 -1
View File
@@ -34,6 +34,15 @@ type Health struct {
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, "")} }
+13 -2
View File
@@ -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."),
}}, }},
} }
+35
View File
@@ -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
+97 -22
View File
@@ -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)
} }
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:b]
} }
// Same markup, apart from the out-of-band mark and what follows the rows on the page. return s[a:]
got := strings.Replace(strings.TrimSpace(frag), ` hx-swap-oob="true"`, "", 1) }
want := strings.TrimSpace(rows(page)) // Same markup, apart from the out-of-band marks and what follows the card on the page.
if !strings.HasPrefix(want, got) { 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&#39;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)
+2
View File
@@ -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"}}
+14 -8
View File
@@ -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}}
+20 -1
View File
@@ -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}}
+17 -4
View File
@@ -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>
+10
View File
@@ -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)
} }
+6
View File
@@ -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)
}) })
+137
View File
@@ -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")
}