feat(panel): give a delivery its history and its own log lines
The delivery page was a list of the fields the send-log table has no column for, stacked one per line down the reading measure. Six values of a few characters each — domain, application, queue id, journal id and two timestamps — came to a page of mostly empty rows, and none of them answered the question the log raises when a row is opened: what actually happened to this message. So the page states that instead. The subject heads it and the sender, recipient and outcome are the line under it, which puts what the message was and how it ended on the first line. Below, two columns: what the journal recorded on the left, as a grid of tiles rather than a stack, and on the right the two timestamps stated as the steps they stand for — accepted and queued, then delivered, deferred, bounced, or refused before queueing. Each step carries its status in the panel's own ok/warn/error/unknown vocabulary, so a colour means here what it means on the status page. A message still queued shows the report it is waiting for as a step that has not happened, rather than dating it with the moment the row was written. Under both, at full width, the mail.log lines for the message's queue id. The queue id was printed on this page as something to go and search the system log for by hand; logtail.QueueLines does that search. It scans a bounded tail of the current file — finding one message's lines means reading rather than seeking — and anchors the match on the character before the id, since queue ids are hexadecimal runs and a shorter one is regularly the tail of a longer one. Send-log rows outlive mail.log (retention ninety days, rotation fourteen files), so a message with nothing left to show says so; that is the normal end state, not a fault, and only a log that cannot be read at all is reported as one. Two cards abreast and a block of raw log lines do not fit the reading measure, so the page now declares itself wide — the opposite of what it did when the column width was unified, where it was the page that stayed prose. The mechanism is unchanged and is why the reversal costs one line: how wide a page needs to be is the page's own property, not the navigation entry's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ package web
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -90,6 +91,91 @@ func TestDeliveryPageShowsWhatTheLogOmits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The page's second column is the message's history: the two timestamps the
|
||||
// journal holds, stated as the steps they stand for, so a row is readable as
|
||||
// what happened to the message rather than as a list of fields.
|
||||
func TestDeliveryPageTellsTheMessagesHistory(t *testing.T) {
|
||||
s, row := serverWithDelivery(t)
|
||||
|
||||
out := getBody(t, s.handleDelivery, "/deliveries/"+itoa(row.ID))
|
||||
for _, want := range []string{
|
||||
"Accepted and queued", "Delivered",
|
||||
row.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
row.UpdatedAt.Format("2006-01-02 15:04:05"),
|
||||
// A delivered message is "ok" in the panel's own badge vocabulary, the
|
||||
// same one the status page and the DNS checks use.
|
||||
`class="st st-ok"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("delivery page is missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
// Accepted comes before delivered: a history read in the wrong order is
|
||||
// worse than none.
|
||||
if strings.Index(out, "Accepted and queued") > strings.Index(out, "Delivered") {
|
||||
t.Errorf("the history is not in the order it happened:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// A queued message has no second timestamp to state, so the step it is waiting
|
||||
// for is drawn as one that has not happened rather than dated with the moment
|
||||
// the row was written.
|
||||
func TestDeliveryPageMarksAQueuedMessageAsStillWaiting(t *testing.T) {
|
||||
s, _ := serverWithDelivery(t)
|
||||
if err := s.store.InsertQueued(store.SendLogEntry{
|
||||
QueueID: "7F7F7F7F", Domain: "bs.example.ru", AppLogin: "Queuer3C",
|
||||
From: "noreply@bs.example.ru", To: "waiting@example.ru", Subject: "Still going",
|
||||
}); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
rows, err := s.store.QuerySendLog(store.SendLogFilter{}, 1, 0)
|
||||
if err != nil || len(rows) != 1 {
|
||||
t.Fatalf("query: %v (%d rows)", err, len(rows))
|
||||
}
|
||||
|
||||
out := getBody(t, s.handleDelivery, "/deliveries/"+itoa(rows[0].ID))
|
||||
for _, want := range []string{"Waiting for a delivery report", "pending", "not yet"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("delivery page does not mark the message as still waiting (%q):\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The queue id used to be printed as something to go and search the system log
|
||||
// for by hand; the page does that search now, and shows only this message's
|
||||
// lines.
|
||||
func TestDeliveryPageShowsThisMessagesLogLines(t *testing.T) {
|
||||
s, row := serverWithDelivery(t)
|
||||
s.cfg.MailLogPath = writeMailLog(t,
|
||||
"host postfix/smtpd[20]: 4A1B2C3D: client=mail.example.com[203.0.113.4]",
|
||||
"host postfix/qmgr[10]: 99999999: from=<other@example.ru>, size=500, nrcpt=1 (queue active)",
|
||||
"host postfix/smtp[26]: 4A1B2C3D: to=<public@example.ru>, dsn=2.0.0, status=sent (250 OK)",
|
||||
)
|
||||
|
||||
out := getBody(t, s.handleDelivery, "/deliveries/"+itoa(row.ID))
|
||||
if !strings.Contains(out, "client=mail.example.com") || !strings.Contains(out, "status=sent (250 OK)") {
|
||||
t.Errorf("delivery page does not show this message's log lines:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "99999999") {
|
||||
t.Errorf("delivery page shows another message's log line:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Rows outlive mail.log, and a message the milter refused never reached the
|
||||
// queue at all. Neither is a fault, so neither may render as an error.
|
||||
func TestDeliveryPageExplainsAnEmptyDeliveryLog(t *testing.T) {
|
||||
s, row := serverWithDelivery(t)
|
||||
s.cfg.MailLogPath = filepath.Join(t.TempDir(), "mail.log") // never created
|
||||
|
||||
out := getBody(t, s.handleDelivery, "/deliveries/"+itoa(row.ID))
|
||||
if !strings.Contains(out, "rotated away") {
|
||||
t.Errorf("delivery page does not explain the empty delivery log:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, `class="error"`) || strings.Contains(out, "Could not read the mail log") {
|
||||
t.Errorf("an aged-out delivery log is reported as a failure:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Send-log rows are pruned on the retention window, so a bookmarked delivery
|
||||
// that no longer exists is a 404, not a 500.
|
||||
func TestDeliveryPageNotFound(t *testing.T) {
|
||||
@@ -159,3 +245,14 @@ func getBody(t *testing.T, h http.HandlerFunc, target string) string {
|
||||
}
|
||||
|
||||
func itoa(n int64) string { return strconv.FormatInt(n, 10) }
|
||||
|
||||
// writeMailLog creates a mail.log holding the given lines and returns its path,
|
||||
// for the pages that read the log rather than the journal.
|
||||
func writeMailLog(t *testing.T, lines ...string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "mail.log")
|
||||
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil {
|
||||
t.Fatalf("write mail.log: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user