test-email: a test message from Server › Health
Decided by the owner on 2026-10-13 (out of preflight); the sender is his
choice of 2026-10-16 ("Да, адрес годится"); mockups e92769f, e3b2501,
8f0ccf9, the route guard 75f97c9.
Card 09 on Health, in the right stack's static end (the left stack is
polled whole): one recipient and Send. HandleTestMessage (global role)
takes one address (validate.AccountEmail - no list, nothing that reads as
an option), at most ten an hour counted from the send log, writes the
message's send-log row first (store.InsertTestMessage: queued, the
hostname as domain, no application, no queue id), awaits its Message-ID
(logtail.AwaitMessageID) and hands it to sendmail as notifications go
(notify.Mailer.SendWithID: postmaster@ the hostname, signed by the local
socket with the key of the server's own name). The browser lands on the
row; the tailer gives it its queue id from cleanup's message-id= line
(src/cleanup/cleanup_message.c:639) and from then on its delivery lines
its status. Postfix refusing the hand-over removes the row; sendmail out
of time keeps it (postdrop may have queued it). The sweep closes a row
whose id was never seen once it is no longer awaited
(CloseUnmatchedTestMessages); an id awaited past ten minutes is not taken.
Reviewed for security before the commit (2026-10-17): no high or medium;
its three low findings are fixed in this commit (the unmatched row's
close, the awaited id's age, the timeout keeping the row) and the
security.md bullet says how the queue id is matched.
Tests: TestTestMessage (refusals, the row and the page, Postfix refusing,
the hourly ten), TestTestMessageTimeoutKeepsTheRow, TestTestMessageRows,
TestCloseUnmatchedTestMessages, TestAwaitedMessageID,
TestRunGivesTheTestMessageItsQueueID, TestHealthTestMessageCard; e2e sends
one to the sink: the row is sent with a long queue id, the sink has it
signed d=mail.e2e.test. go vet + go test (26 packages) and make e2e on the
test server for this tree.
Docs: help (word for word with the mockup), guide § Overview and Health,
architecture § Log tailer, security.md, CHANGELOG [Unreleased] § Added,
roadmap row done.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/mixeme/selfpost/internal/domain"
|
||||
"github.com/mixeme/selfpost/internal/health"
|
||||
"github.com/mixeme/selfpost/internal/inbound"
|
||||
"github.com/mixeme/selfpost/internal/notify"
|
||||
"github.com/mixeme/selfpost/internal/postfix"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/auth"
|
||||
@@ -74,6 +75,14 @@ type Handlers struct {
|
||||
dns *dnscheck.Checker
|
||||
machine *health.MachineSampler
|
||||
auth *auth.Module
|
||||
// testMail sends the panel's test message (notify.Mailer; tests
|
||||
// substitute one that records).
|
||||
testMail testMailer
|
||||
}
|
||||
|
||||
// testMailer is what sends the test message: notify.Mailer.SendWithID.
|
||||
type testMailer interface {
|
||||
SendWithID(to, subject, body, id string) error
|
||||
}
|
||||
|
||||
// New builds authenticated panel handlers.
|
||||
@@ -102,6 +111,8 @@ func New(
|
||||
dns: dns,
|
||||
machine: machine,
|
||||
auth: a,
|
||||
// The test message goes as notifications go (test-email).
|
||||
testMail: notify.NewMailer(cfg.Hostname),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
@@ -12,8 +13,10 @@ import (
|
||||
|
||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||
"github.com/mixeme/selfpost/internal/health"
|
||||
"github.com/mixeme/selfpost/internal/logtail"
|
||||
"github.com/mixeme/selfpost/internal/notify"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/validate"
|
||||
"github.com/mixeme/selfpost/internal/web/view"
|
||||
)
|
||||
|
||||
@@ -139,12 +142,94 @@ func (h *Handlers) HandleHealthRecheck(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/server/health?rechecked=1", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// testMessagesPerHour is how many test messages the panel sends in an hour,
|
||||
// counted from the send log, whoever asked: a form that sends mail must not
|
||||
// become a way to send a lot of it.
|
||||
const testMessagesPerHour = 10
|
||||
|
||||
// HandleTestMessage sends the panel's test message (test-email; owner
|
||||
// 2026-10-13, its sender his choice of 2026-10-16): from postmaster@ the
|
||||
// hostname to the one address typed, as notifications go - through
|
||||
// sendmail, signed by the signer's local socket with the key of the
|
||||
// server's own name. The row is written first, with the Message-ID the
|
||||
// tailer awaits to give it its queue id, and the browser goes to it: its
|
||||
// page follows the message to the receiving server. Global role only; one
|
||||
// address (validate.AccountEmail refuses a list, as it does a name that
|
||||
// reads as an option); ten an hour.
|
||||
func (h *Handlers) HandleTestMessage(w http.ResponseWriter, r *http.Request) {
|
||||
p, ok := h.requireGlobal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
from := notify.From(h.cfg.Hostname)
|
||||
to := ""
|
||||
fail := func(status int, msg string) {
|
||||
page := h.healthView(r, "")
|
||||
page.WithTestMessage(from, to, msg)
|
||||
h.view.Render(w, status, "health", page)
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
fail(http.StatusBadRequest, "Invalid form submission.")
|
||||
return
|
||||
}
|
||||
to = strings.TrimSpace(r.PostFormValue("recipient"))
|
||||
if to == "" || validate.AccountEmail(to) != nil {
|
||||
fail(http.StatusBadRequest, "Enter one e-mail address.")
|
||||
return
|
||||
}
|
||||
n, err := h.store.CountTestMessagesSince(from, time.Now().Add(-time.Hour))
|
||||
if err != nil {
|
||||
logf("panel: test message: %v", err)
|
||||
fail(http.StatusInternalServerError, "Internal error. Please try again.")
|
||||
return
|
||||
}
|
||||
if n >= testMessagesPerHour {
|
||||
fail(http.StatusTooManyRequests, "Ten test messages have gone in the last hour. Please try again later.")
|
||||
return
|
||||
}
|
||||
id, err := notify.NewMessageID(h.cfg.Hostname)
|
||||
if err != nil {
|
||||
logf("panel: test message: %v", err)
|
||||
fail(http.StatusInternalServerError, "Internal error. Please try again.")
|
||||
return
|
||||
}
|
||||
const subject = "SelfPost test message"
|
||||
now := time.Now().UTC()
|
||||
body := "A test message from SelfPost at " + h.cfg.Hostname + ", sent from Server › Health on " +
|
||||
now.Format("2006-01-02 at 15:04 UTC") + ".\n\nIt came from this server, signed with the DKIM key of its own name. " +
|
||||
"Its page in the panel's Outbound log shows what this mail server answered.\n"
|
||||
row, err := h.store.InsertTestMessage(h.cfg.Hostname, from, to, subject)
|
||||
if err != nil {
|
||||
logf("panel: test message: %v", err)
|
||||
fail(http.StatusInternalServerError, "Internal error. Please try again.")
|
||||
return
|
||||
}
|
||||
logtail.AwaitMessageID(id, row)
|
||||
if err := h.testMail.SendWithID(to, subject, body, id); errors.Is(err, notify.ErrTimeout) {
|
||||
// postdrop may have queued it after all: the row and the awaited id
|
||||
// stay, and the row's page shows what became of it - or the sweep
|
||||
// closes the row once the id is no longer awaited.
|
||||
logf("panel: test message to %s (send log %d): %v", to, row, err)
|
||||
} else if err != nil {
|
||||
logtail.ForgetMessageID(id)
|
||||
if derr := h.store.DeleteSendLogRow(row); derr != nil {
|
||||
logf("panel: test message: %v", derr)
|
||||
}
|
||||
logf("panel: test message to %s: %v", to, err)
|
||||
fail(http.StatusBadGateway, "Postfix did not take the message; the container log says why.")
|
||||
return
|
||||
}
|
||||
logf("panel: user %d sent a test message to %s (send log %d)", p.ID, to, row)
|
||||
http.Redirect(w, r, "/outbound/log/"+strconv.FormatInt(row, 10), 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)
|
||||
seen := h.auth.Seen(r)
|
||||
page.WithClientAddress(seen.Client, seen.Peer, seen.FromHeader, seen.HeaderIgnored)
|
||||
page.WithTestMessage(notify.From(h.cfg.Hostname), "", "")
|
||||
mail := h.serverMail(false)
|
||||
page.WithNotificationMail(notify.From(h.cfg.Hostname), mail.rows, mail.status)
|
||||
stats, err := h.store.SenderCheckStats(time.Now())
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/notify"
|
||||
)
|
||||
|
||||
// recordingMailer stands in for notify.Mailer: what was sent, or the error to
|
||||
// answer with.
|
||||
type recordingMailer struct {
|
||||
to, subject, body, id []string
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *recordingMailer) SendWithID(to, subject, body, id string) error {
|
||||
if m.err != nil {
|
||||
return m.err
|
||||
}
|
||||
m.to, m.subject, m.body, m.id = append(m.to, to), append(m.subject, subject), append(m.body, body), append(m.id, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// The test message (test-email): one address, from postmaster@ the hostname,
|
||||
// written to the send log before it goes and opened there; refused for a
|
||||
// list or a non-address with the form kept; not left in the log when Postfix
|
||||
// does not take it; ten an hour.
|
||||
func TestTestMessage(t *testing.T) {
|
||||
h, _, _ := serverMailStand(t)
|
||||
mail := &recordingMailer{}
|
||||
h.testMail = mail
|
||||
send := func(to string) (int, string, string) {
|
||||
rec := postFormAs(h.HandleTestMessage, globalPrincipal, "/server/health/test-message", nil, url.Values{"recipient": {to}})
|
||||
return rec.Code, rec.Header().Get("Location"), rec.Body.String()
|
||||
}
|
||||
|
||||
for _, bad := range []string{"", "a@example.net, b@example.net", "not an address", "-oQ@example.net"} {
|
||||
code, _, body := send(bad)
|
||||
if code != http.StatusBadRequest || !strings.Contains(body, `<p class="help is-danger">Enter one e-mail address.</p>`) {
|
||||
t.Fatalf("recipient %q = %d:\n%.800s", bad, code, body)
|
||||
}
|
||||
}
|
||||
if len(mail.to) != 0 {
|
||||
t.Fatalf("something was sent for a refused recipient: %q", mail.to)
|
||||
}
|
||||
|
||||
code, loc, _ := send("ops@elsewhere.example")
|
||||
if code != http.StatusSeeOther || !strings.HasPrefix(loc, "/outbound/log/") {
|
||||
t.Fatalf("send = %d to %q", code, loc)
|
||||
}
|
||||
if len(mail.to) != 1 || mail.to[0] != "ops@elsewhere.example" || mail.subject[0] != "SelfPost test message" ||
|
||||
!strings.HasPrefix(mail.id[0], "<") || !strings.HasSuffix(mail.id[0], "@"+notifyHost+">") {
|
||||
t.Fatalf("sent %+v", mail)
|
||||
}
|
||||
out := getBody(t, h.HandleDelivery, loc)
|
||||
if !strings.Contains(out, "ops@elsewhere.example") || !strings.Contains(out, "postmaster@"+notifyHost) {
|
||||
t.Errorf("the test message's page:\n%.2000s", out)
|
||||
}
|
||||
|
||||
// Postfix does not take it: nothing stays in the log, the form says so.
|
||||
mail.err = errors.New("sendmail: exit status 75")
|
||||
code, _, body := send("ops@elsewhere.example")
|
||||
if code != http.StatusBadGateway || !strings.Contains(body, "Postfix did not take the message") ||
|
||||
!strings.Contains(body, `value="ops@elsewhere.example"`) {
|
||||
t.Fatalf("a refused hand-over = %d:\n%.800s", code, body)
|
||||
}
|
||||
if n, _ := h.store.CountTestMessagesSince("postmaster@"+notifyHost, timeAgo()); n != 1 {
|
||||
t.Fatalf("%d test messages in the log, want the one Postfix took", n)
|
||||
}
|
||||
|
||||
// Ten an hour, whoever asks.
|
||||
mail.err = nil
|
||||
for i := 0; i < 9; i++ {
|
||||
if code, _, _ := send("ops@elsewhere.example"); code != http.StatusSeeOther {
|
||||
t.Fatalf("message %d of ten = %d", i+2, code)
|
||||
}
|
||||
}
|
||||
if code, _, body := send("ops@elsewhere.example"); code != http.StatusTooManyRequests || !strings.Contains(body, "Ten test messages have gone in the last hour.") {
|
||||
t.Fatalf("the eleventh = %d:\n%.600s", code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func timeAgo() time.Time { return time.Now().Add(-time.Hour) }
|
||||
|
||||
// sendmail out of time may have queued the message all the same: the row
|
||||
// stays and the browser goes to it.
|
||||
func TestTestMessageTimeoutKeepsTheRow(t *testing.T) {
|
||||
h, _, _ := serverMailStand(t)
|
||||
h.testMail = &recordingMailer{err: fmt.Errorf("%w after 20s", notify.ErrTimeout)}
|
||||
rec := postFormAs(h.HandleTestMessage, globalPrincipal, "/server/health/test-message", nil, url.Values{"recipient": {"ops@elsewhere.example"}})
|
||||
if rec.Code != http.StatusSeeOther || !strings.HasPrefix(rec.Header().Get("Location"), "/outbound/log/") {
|
||||
t.Fatalf("a timed-out hand-over = %d to %q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
if n, _ := h.store.CountTestMessagesSince("postmaster@"+notifyHost, timeAgo()); n != 1 {
|
||||
t.Fatalf("%d rows, want the one kept", n)
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@ func TestCardsBoxesCarryTheirNumberAsTheirOrder(t *testing.T) {
|
||||
t.Errorf("%s: panel.css has no rule for data-order %s", name, order)
|
||||
}
|
||||
}
|
||||
if want := map[string]int{"health": 8, "kit": 3}[name]; found != want {
|
||||
if want := map[string]int{"health": 9, "kit": 3}[name]; found != want {
|
||||
t.Errorf("%s: %d numbered boxes found in the cards layout, want %d", name, found, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,25 @@ type Health struct {
|
||||
// reverse proxy's own.
|
||||
ConfigFacts []Fact
|
||||
ConfigProblem Text
|
||||
|
||||
// TestMessage is the card that sends the panel's test message
|
||||
// (test-email): TestFrom is its sender, TestTo the address typed and
|
||||
// TestErr why it was not sent, both kept when the form comes back.
|
||||
TestMessage Box
|
||||
TestAction string
|
||||
TestFrom string
|
||||
TestTo string
|
||||
TestErr string
|
||||
}
|
||||
|
||||
// TestMessageAction is where the Test message card posts.
|
||||
const TestMessageAction = "/server/health/test-message"
|
||||
|
||||
// WithTestMessage fills the Test message card: from is the sender, to and
|
||||
// problem what the form comes back with when it was refused.
|
||||
func (h *Health) WithTestMessage(from, to, problem string) *Health {
|
||||
h.TestFrom, h.TestTo, h.TestErr = from, to, problem
|
||||
return h
|
||||
}
|
||||
|
||||
// WithClientAddress says where the panel sees the viewer's request from:
|
||||
@@ -184,6 +203,9 @@ func NewHealth(m Meta, flash string) *Health {
|
||||
Configuration: Box{No: "08", InCards: true, Title: "Configuration", ID: "configuration",
|
||||
End: Plain("Safe to run at any time")},
|
||||
Reload: Action{Label: "Reload configuration", Icon: "ti-reload", Post: "/server/health/reload"},
|
||||
TestMessage: Box{No: "09", InCards: true, Title: "Test message", ID: "test-message",
|
||||
End: Plain("Shows what the receiving server answers"), Help: topicLink(HelpTestMessage, "What the test proves")},
|
||||
TestAction: TestMessageAction,
|
||||
}
|
||||
if flash != "" {
|
||||
h.Flash = &Flash{Text: Plain(flash)}
|
||||
|
||||
@@ -40,6 +40,7 @@ const (
|
||||
HelpChecks = "checks"
|
||||
HelpCertificate = "certificate"
|
||||
HelpRDNS = "rdns"
|
||||
HelpTestMessage = "test-message"
|
||||
// Outbound
|
||||
HelpDNS = "dns"
|
||||
HelpApps = "apps"
|
||||
@@ -99,6 +100,7 @@ func NewHelp(m Meta, inbound bool) *Help {
|
||||
add("Overview", overviewTopics, []MenuItem{
|
||||
{Label: "Health checks", Icon: "ti-activity-heartbeat", Href: "#" + HelpChecks},
|
||||
{Label: "TLS certificate", Icon: "ti-certificate", Href: "#" + HelpCertificate},
|
||||
{Label: "Test message", Icon: "ti-send", Href: "#" + HelpTestMessage},
|
||||
{Label: "Reverse DNS", Icon: "ti-arrows-exchange", Href: "#" + HelpRDNS},
|
||||
})
|
||||
}
|
||||
@@ -153,6 +155,10 @@ var overviewTopics = []HelpTopic{
|
||||
Rich("The certificate Postfix presents on 465 and 587. The reverse proxy or the image mounts it, the panel does not issue it. Postfix serves its own copy of the mounted file and looks for a renewed one once a day by default; until it takes one up, the card shows it as a renewal waiting, and warns once it has waited longer than that check allows - the container log's cert-reload lines say why. It warns 14 days before expiry unless a renewal is already waiting, and fails once expired, or when the file is missing or unreadable — clients that verify certificates then refuse to connect. It also fails when none of its names is the hostname (",
|
||||
Code("SELFPOST_HOSTNAME"), ") - the tag beside ", Em("Names"), " says which: a client that verifies the name refuses it however valid it is."),
|
||||
}},
|
||||
{ID: HelpTestMessage, Title: "Test message", Body: []Text{
|
||||
Rich("A message from ", Code("postmaster@"), " the server's hostname to one address you type, sent the way notifications are: through Postfix, signed with the DKIM key of the server's own name. It proves the path from this server - TLS, its address's reputation, the SPF and DKIM records of its name - not your domains', which only mail from an application does. The page that opens is its row in the Outbound log: it shows whether the receiving server took it, put it off or refused it, with the lines of ",
|
||||
Code("mail.log"), ". Whether it then reached the inbox or the spam folder only the mailbox can say. The global role sends it, at most ten an hour."),
|
||||
}},
|
||||
{ID: HelpRDNS, Title: "Hostname and reverse DNS", Body: []Text{
|
||||
Rich("The hostname (", Code("SELFPOST_HOSTNAME"), ") must resolve to this server's address, and that address must resolve back to the hostname. Large receivers reject or spam-folder mail from hosts where the two disagree. The PTR record is set at your hosting provider, not in your DNS zone. ",
|
||||
Em("Greeting"), " is the line Postfix opens every connection with, ", Code("220"), " and its banner; it must name the hostname too - a receiver compares it with the name it looked up - and a container id or ",
|
||||
|
||||
@@ -346,3 +346,16 @@ func TestHealthSaysWhereItSeesTheViewer(t *testing.T) {
|
||||
out = renderSignedIn(t, "health", NewHealth(admin(), "").WithClientAddress("fd00::1", "fd00::1", false, true))
|
||||
pageHas(t, "Health", out, `<code>fd00::1/128</code>`)
|
||||
}
|
||||
|
||||
// Health's Test message card posts one address and keeps it, with the reason,
|
||||
// when it comes back refused.
|
||||
func TestHealthTestMessageCard(t *testing.T) {
|
||||
out := renderSignedIn(t, "health", NewHealth(admin(), "").WithTestMessage("postmaster@mail.example.org", "", ""))
|
||||
pageHas(t, "Health", out, `<form class="sp-form" method="post" action="/server/health/test-message">`,
|
||||
`name="recipient" type="email" placeholder="you@elsewhere.example" value="" required>`,
|
||||
`From <span class="sp-mono">postmaster@mail.example.org</span>, signed with the server's own key`)
|
||||
pageLacks(t, "Health", out, `help is-danger`)
|
||||
|
||||
out = renderSignedIn(t, "health", NewHealth(admin(), "").WithTestMessage("postmaster@mail.example.org", "a@x, b@y", "Enter one e-mail address."))
|
||||
pageHas(t, "Health", out, `class="input is-danger" id="rcpt"`, `value="a@x, b@y"`, `<p class="help is-danger">Enter one e-mail address.</p>`)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,19 @@
|
||||
{{template "action" .Reload}}
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
{{template "box_open" .TestMessage}}
|
||||
{{template "body_open"}}
|
||||
<form class="sp-form" method="post" action="{{.TestAction}}">
|
||||
<div class="field"><label class="label" for="rcpt">Recipient</label>
|
||||
<div class="field has-addons">
|
||||
<div class="control is-expanded has-icons-left"><input class="input{{if .TestErr}} is-danger{{end}}" id="rcpt" name="recipient" type="email" placeholder="you@elsewhere.example" value="{{.TestTo}}" required><span class="icon is-left"><i class="ti ti-mail"></i></span></div>
|
||||
<div class="control"><button type="submit" class="button is-primary"><i class="ti ti-send"></i>Send</button></div>
|
||||
</div>
|
||||
{{if .TestErr}}<p class="help is-danger">{{.TestErr}}</p>
|
||||
{{end}}<p class="help">From <span class="sp-mono">{{.TestFrom}}</span>, signed with the server's own key, through Postfix as its notifications go. It opens in the <a href="/outbound/log">Outbound log</a>, where its page follows it to the receiving server. Ten an hour.</p></div>
|
||||
</form>
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
</div>
|
||||
{{template "cards_close"}}
|
||||
{{end}}
|
||||
|
||||
@@ -284,6 +284,7 @@ func (s *Server) muxes() (public, authed *http.ServeMux) {
|
||||
server("GET /server/health/fragment", h.HandleHealthFragment)
|
||||
server("POST /server/health/recheck", h.HandleHealthRecheck)
|
||||
server("POST /server/health/reload", h.HandleReload)
|
||||
server("POST /server/health/test-message", h.HandleTestMessage)
|
||||
server("GET /server/log", h.HandleSystemLog)
|
||||
server("GET /server/log/fragment", h.HandleSystemLogBody)
|
||||
server("GET /server/backup", h.HandleBackupPage)
|
||||
|
||||
Reference in New Issue
Block a user