Phase 7: monitoring UI — send log, queue, mail.log tail

Three HTMX-polled monitoring screens (spec 7.2.11-13): send log with
server-side domain/application filters and pagination, Postfix queue
(postqueue -p), and a mail.log tail. Fragment endpoints return HTML
snippets, not JSON (spec 7.1); all output is auto-escaped via
html/template (spec 7.6.7).

Adds store.QuerySendLog/CountSendLog/ListApplicationLogins,
postfix.Queue(), and logtail.TailLines (a point-in-time reverse read,
independent of the background follow loop). Verified on the dev server:
gofmt/vet/test green, docker build green, container e2e (filters,
60-row pagination, <script> escaping, real postqueue/mail.log output,
existing Reload button unaffected).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 23:22:08 +03:00
parent 901fc0e05a
commit 9d4942aef6
16 changed files with 558 additions and 16 deletions
+84
View File
@@ -2,6 +2,7 @@ package store
import (
"fmt"
"strings"
"time"
)
@@ -65,6 +66,89 @@ func (s *Store) UpdateStatus(queueID, recipient, status string) (int64, error) {
return n, nil
}
// SendLogRow is one row as returned to the monitoring UI (spec 7.2, 7.3.3): a
// SendLogEntry plus the fields that only exist once a row has been written
// (id, current status, timestamps).
type SendLogRow struct {
ID int64
QueueID string
Domain string
AppLogin string
From string
To string
Subject string
Status string
CreatedAt time.Time
}
// SendLogFilter narrows QuerySendLog/CountSendLog by domain and/or
// application login. An empty field matches everything.
type SendLogFilter struct {
Domain string
AppLogin string
}
// QuerySendLog returns send-log rows matching filter, newest first, for the
// monitoring screen's server-side pagination (spec 7.2's send-log view).
func (s *Store) QuerySendLog(filter SendLogFilter, limit, offset int) ([]SendLogRow, error) {
where, args := sendLogWhere(filter)
args = append(args, limit, offset)
rows, err := s.db.Query(
`SELECT id, queue_id, domain, app_login, from_addr, to_addr, subject, status, created_at
FROM send_log`+where+`
ORDER BY id DESC
LIMIT ? OFFSET ?`,
args...,
)
if err != nil {
return nil, fmt.Errorf("query send_log: %w", err)
}
defer rows.Close()
var out []SendLogRow
for rows.Next() {
var (
row SendLogRow
createdAt string
)
if err := rows.Scan(&row.ID, &row.QueueID, &row.Domain, &row.AppLogin,
&row.From, &row.To, &row.Subject, &row.Status, &createdAt); err != nil {
return nil, fmt.Errorf("scan send_log row: %w", err)
}
row.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
out = append(out, row)
}
return out, rows.Err()
}
// CountSendLog returns how many send-log rows match filter, so the monitoring
// screen can render page numbers/next-prev links.
func (s *Store) CountSendLog(filter SendLogFilter) (int64, error) {
where, args := sendLogWhere(filter)
var n int64
if err := s.db.QueryRow(`SELECT COUNT(*) FROM send_log`+where, args...).Scan(&n); err != nil {
return 0, fmt.Errorf("count send_log: %w", err)
}
return n, nil
}
func sendLogWhere(f SendLogFilter) (string, []any) {
var clauses []string
var args []any
if f.Domain != "" {
clauses = append(clauses, "domain = ?")
args = append(args, f.Domain)
}
if f.AppLogin != "" {
clauses = append(clauses, "app_login = ?")
args = append(args, f.AppLogin)
}
if len(clauses) == 0 {
return "", nil
}
return " WHERE " + strings.Join(clauses, " AND "), args
}
// DeleteSendLogBefore removes send-log rows created before cutoff, implementing
// the configurable retention window (spec 7.3, SEND_LOG_RETENTION_DAYS). It
// returns the number of rows pruned. created_at is stored as RFC3339 UTC, so a