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>
104 lines
4.0 KiB
Go
104 lines
4.0 KiB
Go
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)
|
|
}
|
|
}
|