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:
@@ -76,6 +76,71 @@ func (s *Store) InsertQueued(e SendLogEntry) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// InsertTestMessage records the panel's own test message (Server › Health,
|
||||
// test-email) as queued, before it is handed to Postfix: from postmaster@ the
|
||||
// hostname, under the hostname as its domain, with no application and no
|
||||
// queue id yet - cleanup assigns one, and SetSendLogQueueID puts it on the
|
||||
// row once the tailer reads it. It returns the row's id.
|
||||
func (s *Store) InsertTestMessage(domain, from, to, subject string) (int64, error) {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
res, err := s.db.Exec(
|
||||
`INSERT INTO send_log (queue_id, domain, app_login, from_addr, to_addr, subject, status, created_at, updated_at)
|
||||
VALUES ('', ?, '', ?, ?, ?, ?, ?, ?)`,
|
||||
domain, from, to, subject, StatusQueued, now, now,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert test message: %w", err)
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// SetSendLogQueueID gives a row that has none the queue id Postfix assigned
|
||||
// its message. A row that has one keeps it.
|
||||
func (s *Store) SetSendLogQueueID(id int64, queueID string) error {
|
||||
if _, err := s.db.Exec(`UPDATE send_log SET queue_id = ? WHERE id = ? AND queue_id = ''`, queueID, id); err != nil {
|
||||
return fmt.Errorf("set queue id of send_log %d: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CloseUnmatchedTestMessages closes the test messages written before then
|
||||
// that never learned a queue id - the panel restarted between the hand-over
|
||||
// and cleanup's line, or the line was missed: nothing will ever report on
|
||||
// them. They are closed as the sweep closes a row (bounced, reason
|
||||
// reconcile), so their page says the panel decided it.
|
||||
func (s *Store) CloseUnmatchedTestMessages(before time.Time) (int64, error) {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
res, err := s.db.Exec(
|
||||
`UPDATE send_log SET status = ?, reason = ?, updated_at = ?
|
||||
WHERE queue_id = '' AND app_login = '' AND status = ? AND created_at < ?`,
|
||||
StatusBounced, ReasonReconcile, now, StatusQueued, before.UTC().Format(time.RFC3339))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("close test messages with no queue id: %w", err)
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
// DeleteSendLogRow removes one row: a test message Postfix did not take.
|
||||
func (s *Store) DeleteSendLogRow(id int64) error {
|
||||
if _, err := s.db.Exec(`DELETE FROM send_log WHERE id = ?`, id); err != nil {
|
||||
return fmt.Errorf("delete send_log %d: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountTestMessagesSince counts the test messages sent from addr since then:
|
||||
// rows from that address with no application, the shape InsertTestMessage
|
||||
// writes.
|
||||
func (s *Store) CountTestMessagesSince(addr string, since time.Time) (int, error) {
|
||||
var n int
|
||||
err := s.db.QueryRow(`SELECT COUNT(*) FROM send_log WHERE from_addr = ? AND app_login = '' AND created_at >= ?`,
|
||||
addr, since.UTC().Format(time.RFC3339)).Scan(&n)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count test messages: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// InsertRejected records a refused message (StatusRejected), so the refusal is
|
||||
// visible in the send-log UI with its reason. The journal-milter writes its
|
||||
// MAIL FROM refusals with only the fields known there (domain, sender, app
|
||||
|
||||
@@ -500,3 +500,63 @@ func TestALoginTakenAgainStartsWithNoNumbers(t *testing.T) {
|
||||
t.Fatalf("level-2 count of the new news = %d, want its own one", n)
|
||||
}
|
||||
}
|
||||
|
||||
// The test message's row: written queued with no queue id, given the one
|
||||
// cleanup assigned (and only once), counted per sender and hour, removed
|
||||
// when Postfix did not take it.
|
||||
func TestTestMessageRows(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
from := "postmaster@mail.example.org"
|
||||
id, err := st.InsertTestMessage("mail.example.org", from, "ops@elsewhere.example", "SelfPost test message")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SetSendLogQueueID(id, "4j5qN53nwRz791P4"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SetSendLogQueueID(id, "other"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
row, err := st.GetSendLog(id)
|
||||
if err != nil || row.QueueID != "4j5qN53nwRz791P4" || row.Status != StatusQueued || row.AppLogin != "" || row.Domain != "mail.example.org" {
|
||||
t.Fatalf("row = %+v, %v", row, err)
|
||||
}
|
||||
if n, err := st.UpdateStatus("4j5qN53nwRz791P4", "ops@elsewhere.example", StatusSent); err != nil || n != 1 {
|
||||
t.Fatalf("its delivery line = %d, %v", n, err)
|
||||
}
|
||||
if n, err := st.CountTestMessagesSince(from, time.Now().Add(-time.Hour)); err != nil || n != 1 {
|
||||
t.Fatalf("count = %d, %v", n, err)
|
||||
}
|
||||
if n, _ := st.CountTestMessagesSince(from, time.Now().Add(time.Minute)); n != 0 {
|
||||
t.Fatalf("count from the future = %d", n)
|
||||
}
|
||||
if err := st.DeleteSendLogRow(id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, _ := st.CountTestMessagesSince(from, time.Now().Add(-time.Hour)); n != 0 {
|
||||
t.Fatalf("a deleted row is counted")
|
||||
}
|
||||
}
|
||||
|
||||
// A test message that never learned its queue id is closed by the sweep once
|
||||
// it is no longer awaited; one that did, and any application's row, is not.
|
||||
func TestCloseUnmatchedTestMessages(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
lost, _ := st.InsertTestMessage("mail.example.org", "postmaster@mail.example.org", "a@elsewhere.example", "SelfPost test message")
|
||||
found, _ := st.InsertTestMessage("mail.example.org", "postmaster@mail.example.org", "b@elsewhere.example", "SelfPost test message")
|
||||
if err := st.SetSendLogQueueID(found, "Q1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, err := st.CloseUnmatchedTestMessages(time.Now().Add(-time.Hour)); err != nil || n != 0 {
|
||||
t.Fatalf("a fresh row was closed: %d, %v", n, err)
|
||||
}
|
||||
if n, err := st.CloseUnmatchedTestMessages(time.Now().Add(time.Minute)); err != nil || n != 1 {
|
||||
t.Fatalf("closed %d, %v; want the one with no queue id", n, err)
|
||||
}
|
||||
if r, _ := st.GetSendLog(lost); r.Status != StatusBounced || r.Reason != ReasonReconcile {
|
||||
t.Errorf("the lost row = %+v", r)
|
||||
}
|
||||
if r, _ := st.GetSendLog(found); r.Status != StatusQueued {
|
||||
t.Errorf("the row with a queue id = %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user