release: 1.0.0: Public 1.0 image; delivery log; docs and SECURITY.md (incl. 1.0.1)
[1.0.0]
Added:
- A **Delivery log** on each delivery's page (`/deliveries/{id}`): the
- A **History** block on the same page: the journal's two timestamps stated as
Fixed:
- Postfix config: copy TLS cert/key from the (often `:ro`, host-owned) mount
- Postfix config: allow `maillog_file` under `/data` via
- E2e: read `/data/setup-token` via `docker compose exec` (file is `0600`
- Entrypoint: check `SELFPOST_HOSTNAME` before `/data` setup (so a bad identity
- E2e gate: wait for host-published `/healthz` before panel setup, and stop
… (3 more in CHANGELOG)
Changed:
- Document what `.spbk` and `.spde` stand for (SelfPost backup / SelfPost domain
- Docs aligned with the code: setup URL is `/setup/<token>` (README and
- Full backups no longer carry `/data/log`. It is Postfix's raw log plus its
- Monitoring screens (status, mail queue, system log, deliveries) use adaptive
- Documentation package consolidated into `docs/development.md`: Documentation
… (4 more in CHANGELOG)
[1.0.1]
A documentation and packaging release: no change to the mail path, the database, or the on-disk layout. Upgrading is a tag bump.
Added:
- `SECURITY.md` — how to report a vulnerability privately (GitHub private
- [docs/plans/](docs/plans/) — one document per agreed extension: the optional
Changed:
- The panel's **Account** entry is now called **Settings** — nav link, page
- The signed-in name in the panel's nav is now labelled `User:`, so it reads as
- [docs/product.md](docs/product.md) reframes the future line: the inbound
- [docs/security.md](docs/security.md) is now in English, matching the rest of
- The README documentation table now points at `SECURITY.md` for reporting a
… (2 more in CHANGELOG)
Fixed:
- The panel's static assets are served with a content ETag and
Squashed CHANGELOG sections: [1.0.0], [1.0.1].
Full detail: CHANGELOG.md (1.0.0, 1.0.1).
This commit is contained in:
+245
-5
@@ -16,16 +16,20 @@ import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/postfix"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// StatusStore is the slice of the store the log-tailer needs: advancing
|
||||
// delivery statuses, pruning the retention window, and remembering how far into
|
||||
// mail.log it has read. *store.Store satisfies it.
|
||||
// delivery statuses, finding the rows still waiting for one, pruning the
|
||||
// retention window, and remembering how far into mail.log it has read.
|
||||
// *store.Store satisfies it.
|
||||
type StatusStore interface {
|
||||
UpdateStatus(queueID, recipient, status string) (int64, error)
|
||||
ListQueuedOlderThan(cutoff time.Time) ([]store.QueuedDelivery, error)
|
||||
DeleteSendLogBefore(cutoff time.Time) (int64, error)
|
||||
LogtailState(path string) (store.LogtailState, bool, error)
|
||||
SaveLogtailState(path string, st store.LogtailState) error
|
||||
@@ -35,13 +39,24 @@ type StatusStore interface {
|
||||
// is a var so tests can shorten it.
|
||||
var pollInterval = time.Second
|
||||
|
||||
// queueIDs lists the messages Postfix currently holds, for the reconcile sweep.
|
||||
// It is a var so tests can answer without a running Postfix.
|
||||
var queueIDs = postfix.QueueIDs
|
||||
|
||||
const (
|
||||
// retentionInterval is how often the retention sweep runs (also once at
|
||||
// startup). The window itself is configurable; the cadence need not be.
|
||||
retentionInterval = 6 * time.Hour
|
||||
// defaultRetentionDays applies when the configured value is unset/invalid
|
||||
// (README § Environment variables: SEND_LOG_RETENTION_DAYS).
|
||||
// (guide § Environment variables: SEND_LOG_RETENTION_DAYS).
|
||||
defaultRetentionDays = 90
|
||||
// reconcileInterval is how often the sweep compares stuck rows against the
|
||||
// Postfix queue, and reconcileGrace how long a row is left alone first.
|
||||
// The grace covers the ordinary lag between the milter writing the row and
|
||||
// Postfix logging the result — seconds, generously rounded up — so a
|
||||
// message merely in flight is never touched.
|
||||
reconcileInterval = 5 * time.Minute
|
||||
reconcileGrace = 2 * time.Minute
|
||||
)
|
||||
|
||||
// deliveryRe matches a Postfix delivery line and captures queue-id, recipient
|
||||
@@ -89,7 +104,14 @@ func parseDelivery(line string) (queueID, recipient, status string, ok bool) {
|
||||
func Run(ctx context.Context, path string, st StatusStore, retentionDays int) error {
|
||||
go retentionLoop(ctx, st, retentionDays)
|
||||
|
||||
return follow(ctx, path, &tracker{st: st, path: path}, func(line string) {
|
||||
// The reconcile sweep must not run against a backlog the tailer has not
|
||||
// read yet: on a restart the log holds the very lines that resolve the rows
|
||||
// the sweep would otherwise close. follow() closes this once it has read to
|
||||
// end-of-file for the first time.
|
||||
caughtUp := make(chan struct{})
|
||||
go reconcileLoop(ctx, st, caughtUp)
|
||||
|
||||
return follow(ctx, path, &tracker{st: st, path: path}, caughtUp, func(line string) {
|
||||
queueID, recipient, status, ok := parseDelivery(line)
|
||||
if !ok {
|
||||
return
|
||||
@@ -100,6 +122,75 @@ func Run(ctx context.Context, path string, st StatusStore, retentionDays int) er
|
||||
})
|
||||
}
|
||||
|
||||
// reconcileLoop periodically closes send-log rows Postfix has stopped working
|
||||
// on (architecture.md § Log tailer). It starts only once the tailer has caught
|
||||
// up with the log, and then leaves the first sweep a full interval away, so a
|
||||
// restart resolves rows from the log — the accurate source — before the sweep
|
||||
// gets to guess at whatever the log could not explain.
|
||||
func reconcileLoop(ctx context.Context, st StatusStore, caughtUp <-chan struct{}) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-caughtUp:
|
||||
}
|
||||
|
||||
t := time.NewTicker(reconcileInterval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
reconcile(st, time.Now().UTC().Add(-reconcileGrace))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reconcile marks as bounced every row still "queued" from before cutoff whose
|
||||
// message Postfix no longer holds.
|
||||
//
|
||||
// A row reaches this state only when its delivery lines are gone for good — the
|
||||
// log rotated past its fourteen files while the panel was down, or was deleted
|
||||
// — since the log itself now outlives the container. Postfix having dropped the
|
||||
// message means it will never report anything more about it, so the row can
|
||||
// only be closed on an assumption; it is closed as a failure rather than a
|
||||
// success because a delivery the panel cannot evidence must not be shown as
|
||||
// one. Rows whose message is still in the queue, and every row when the queue
|
||||
// cannot be listed at all, are left exactly as they are.
|
||||
func reconcile(st StatusStore, cutoff time.Time) {
|
||||
rows, err := st.ListQueuedOlderThan(cutoff)
|
||||
if err != nil {
|
||||
log.Printf("log-tailer: reconcile: list queued rows: %v", err)
|
||||
return
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
held, err := queueIDs()
|
||||
if err != nil {
|
||||
// No listing is no information: closing rows now would be a guess made
|
||||
// against nothing.
|
||||
log.Printf("log-tailer: reconcile: read postfix queue: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var closed int
|
||||
for _, row := range rows {
|
||||
if _, still := held[row.QueueID]; still {
|
||||
continue
|
||||
}
|
||||
if _, err := st.UpdateStatus(row.QueueID, row.To, store.StatusBounced); err != nil {
|
||||
log.Printf("log-tailer: reconcile: close %s/%s: %v", row.QueueID, row.To, err)
|
||||
continue
|
||||
}
|
||||
closed++
|
||||
}
|
||||
if closed > 0 {
|
||||
log.Printf("log-tailer: reconcile: closed %d row(s) Postfix no longer holds and never reported", closed)
|
||||
}
|
||||
}
|
||||
|
||||
// retentionLoop prunes expired send-log rows immediately and then periodically.
|
||||
func retentionLoop(ctx context.Context, st StatusStore, retentionDays int) {
|
||||
if retentionDays <= 0 {
|
||||
@@ -176,12 +267,158 @@ func TailLines(path string, n int) ([]string, error) {
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
// queueScanBytes bounds how far back QueueLines reads. A message's own lines
|
||||
// are a handful, but they are scattered through everything else the mail path
|
||||
// logged around them, so finding them means reading rather than seeking. The
|
||||
// budget is what keeps that read bounded on a log that has grown for a day:
|
||||
// beyond it the answer is "not in the part of the log still on disk", which is
|
||||
// the same answer rotation gives and is reported the same way. It is a var so
|
||||
// tests can shrink it, the way pollInterval above is.
|
||||
var queueScanBytes int64 = 4 << 20
|
||||
|
||||
// QueueLines returns the mail.log lines Postfix wrote about one queue id,
|
||||
// oldest first and at most n of them, for a single delivery's page
|
||||
// (architecture.md § Panel HTTP surface). It reads the tail of the log the way
|
||||
// TailLines does — one-shot, on request, unrelated to the follow loop above —
|
||||
// but keeps only the lines belonging to this message instead of the last n of
|
||||
// everything.
|
||||
//
|
||||
// A message older than the tail scanned, or older than the current log file,
|
||||
// comes back empty rather than as an error: send-log rows outlive mail.log
|
||||
// (retention is ninety days by default, rotation keeps fourteen files), so a
|
||||
// row with nothing left to show for it is expected.
|
||||
func QueueLines(path, queueID string, n int) ([]string, error) {
|
||||
// No queue-id, no lines: a message the milter refused was never queued, so
|
||||
// there is nothing to match on and every line would be someone else's.
|
||||
if queueID == "" || n <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Forward from the start of the budget rather than backwards in chunks:
|
||||
// the lines are wanted oldest first, and the whole budget is read either
|
||||
// way, so reading it in order costs nothing and keeps them in order.
|
||||
start := info.Size() - queueScanBytes
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if _, err := f.Seek(start, io.SeekStart); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sc := bufio.NewScanner(f)
|
||||
// A Postfix line carrying a long remote reply can pass the scanner's default
|
||||
// 64KB token; without a bigger ceiling that one line would end the scan and
|
||||
// silently truncate the answer.
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
|
||||
// Reading into the middle of the file lands mid-line; that fragment is
|
||||
// dropped rather than reported as a line of its own.
|
||||
if start > 0 && sc.Scan() {
|
||||
_ = sc.Text()
|
||||
}
|
||||
|
||||
var lines []string
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
if !mentionsQueueID(line, queueID) {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, line)
|
||||
// Keep the latest n rather than stopping at the first n: what a message
|
||||
// did last is what its page is opened for.
|
||||
if len(lines) > n {
|
||||
lines = lines[1:]
|
||||
}
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
// mentionsQueueID reports whether a mail.log line is about queueID. Postfix
|
||||
// writes the id followed by a colon ("… postfix/smtp[26]: 41E862C00D9E: to=…"),
|
||||
// and the match is anchored on the character before it so a shorter id is not
|
||||
// found inside a longer one — queue ids are hexadecimal, and one being the tail
|
||||
// of another is ordinary, not unlikely.
|
||||
func mentionsQueueID(line, queueID string) bool {
|
||||
needle := queueID + ":"
|
||||
for i := 0; i <= len(line)-len(needle); {
|
||||
j := strings.Index(line[i:], needle)
|
||||
if j < 0 {
|
||||
return false
|
||||
}
|
||||
j += i
|
||||
if j == 0 || !isQueueIDByte(line[j-1]) {
|
||||
return true
|
||||
}
|
||||
i = j + 1
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isQueueIDByte(b byte) bool {
|
||||
return b >= '0' && b <= '9' || b >= 'A' && b <= 'Z' || b >= 'a' && b <= 'z'
|
||||
}
|
||||
|
||||
// Timestamps at the head of a mail.log line, in the two formats postlogd
|
||||
// writes (maillog_file in build/postfix-config.sh).
|
||||
//
|
||||
// syslogStampRe is the one that matches in practice today: the format is
|
||||
// controlled by maillog_file_format, which arrived in Postfix 3.9, and the
|
||||
// image is built on Debian's 3.7 — where the parameter does not exist and the
|
||||
// only format is syslog's traditional one. It carries no year and no zone, so
|
||||
// the stamp shown is a wall clock and nothing more, which is all this column
|
||||
// claims to be.
|
||||
//
|
||||
// isoStampRe is for the RFC 3339 format that same parameter selects once the
|
||||
// base image carries a Postfix new enough to offer it. Matching it first costs
|
||||
// one failed anchor per line and means the upgrade needs no change here.
|
||||
var (
|
||||
isoStampRe = regexp.MustCompile(`^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?\s`)
|
||||
syslogStampRe = regexp.MustCompile(`^([A-Z][a-z]{2}\s+\d{1,2} \d{2}:\d{2}:\d{2})\s`)
|
||||
)
|
||||
|
||||
// SplitTimestamp separates the timestamp at the head of a mail.log line from
|
||||
// the rest of it, so a page can put the two in their own columns. The stamp
|
||||
// comes back without its fractional seconds and zone offset — five decimal
|
||||
// places of microsecond are the widest part of the column and the least worth
|
||||
// reading — but is otherwise the log's own wall clock, not converted: what is
|
||||
// on the page is what is in the file.
|
||||
//
|
||||
// A line whose head is not a timestamp this recognises comes back whole, as
|
||||
// rest, with an empty stamp. Nothing is ever dropped: the point of showing the
|
||||
// log is that it says what it says.
|
||||
func SplitTimestamp(line string) (stamp, rest string) {
|
||||
if m := isoStampRe.FindStringSubmatch(line); m != nil {
|
||||
return m[1] + " " + m[2], strings.TrimSpace(line[len(m[0]):])
|
||||
}
|
||||
if m := syslogStampRe.FindStringSubmatch(line); m != nil {
|
||||
return m[1], strings.TrimSpace(line[len(m[0]):])
|
||||
}
|
||||
return "", line
|
||||
}
|
||||
|
||||
// follow tails path line by line, calling handle for each complete line, until
|
||||
// ctx is cancelled. Where it starts is tr's decision (a persisted offset, the
|
||||
// start of a file that changed while the panel was down, or end-of-file on a
|
||||
// first ever run); it reopens the file when it is rotated (inode change from
|
||||
// logrotate's create, or truncation from copytruncate) so nothing is missed.
|
||||
func follow(ctx context.Context, path string, tr *tracker, handle func(string)) error {
|
||||
//
|
||||
// caughtUp is closed after the first read that reaches end-of-file, which is
|
||||
// the point where every line the panel missed while it was down has been
|
||||
// handled.
|
||||
func follow(ctx context.Context, path string, tr *tracker, caughtUp chan struct{}, handle func(string)) error {
|
||||
var (
|
||||
f *os.File
|
||||
r *bufio.Reader
|
||||
@@ -255,6 +492,8 @@ func follow(ctx context.Context, path string, tr *tracker, handle func(string))
|
||||
return pos - int64(len(pending))
|
||||
}
|
||||
|
||||
var once sync.Once
|
||||
|
||||
ticker := time.NewTicker(pollInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
@@ -264,6 +503,7 @@ func follow(ctx context.Context, path string, tr *tracker, handle func(string))
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
drain()
|
||||
once.Do(func() { close(caughtUp) })
|
||||
ni, err := os.Stat(path)
|
||||
if err != nil {
|
||||
continue // file briefly gone mid-rotation; try again next tick
|
||||
|
||||
@@ -2,8 +2,10 @@ package logtail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -141,6 +143,10 @@ type captureStore struct {
|
||||
state store.LogtailState
|
||||
haveState bool
|
||||
stateErr error
|
||||
|
||||
// queued is what ListQueuedOlderThan returns, for the reconcile sweep.
|
||||
queued []store.QueuedDelivery
|
||||
queuedErr error
|
||||
}
|
||||
|
||||
func (c *captureStore) UpdateStatus(queueID, recipient, status string) (int64, error) {
|
||||
@@ -152,6 +158,12 @@ func (c *captureStore) UpdateStatus(queueID, recipient, status string) (int64, e
|
||||
|
||||
func (c *captureStore) DeleteSendLogBefore(time.Time) (int64, error) { return 0, nil }
|
||||
|
||||
func (c *captureStore) ListQueuedOlderThan(time.Time) ([]store.QueuedDelivery, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.queued, c.queuedErr
|
||||
}
|
||||
|
||||
func (c *captureStore) LogtailState(string) (store.LogtailState, bool, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
@@ -333,3 +345,260 @@ func waitFor(t *testing.T, cond func() bool) {
|
||||
}
|
||||
t.Fatal("condition not met within timeout")
|
||||
}
|
||||
|
||||
// stubQueue makes the reconcile sweep answer from a fixed list of queue ids
|
||||
// instead of a running Postfix, or fail if err is non-nil.
|
||||
func stubQueue(t *testing.T, err error, ids ...string) *int {
|
||||
t.Helper()
|
||||
calls := 0
|
||||
old := queueIDs
|
||||
queueIDs = func() (map[string]struct{}, error) {
|
||||
calls++
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
set := make(map[string]struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
set[id] = struct{}{}
|
||||
}
|
||||
return set, nil
|
||||
}
|
||||
t.Cleanup(func() { queueIDs = old })
|
||||
return &calls
|
||||
}
|
||||
|
||||
// A row stays "queued" forever when its delivery lines are gone for good. The
|
||||
// queue is what settles it: a message Postfix still holds is simply in flight
|
||||
// and must be left alone, while one it no longer holds will never be reported
|
||||
// on and is closed.
|
||||
func TestReconcileClosesOnlyWhatPostfixNoLongerHolds(t *testing.T) {
|
||||
stubQueue(t, nil, "STILLQ")
|
||||
|
||||
cs := &captureStore{queued: []store.QueuedDelivery{
|
||||
{QueueID: "STILLQ", To: "inflight@example.net"},
|
||||
{QueueID: "GONEQ", To: "lost@example.net"},
|
||||
}}
|
||||
reconcile(cs, time.Now().UTC())
|
||||
|
||||
got := cs.snapshot()
|
||||
if len(got) != 1 || got[0] != "GONEQ|lost@example.net|"+store.StatusBounced {
|
||||
t.Fatalf("got %v, want only the message Postfix dropped closed as bounced", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A queue that cannot be listed says nothing about any message — treating the
|
||||
// failure as an empty queue would close every stale row at once.
|
||||
func TestReconcileLeavesRowsAloneWhenTheQueueCannotBeRead(t *testing.T) {
|
||||
stubQueue(t, errors.New("postqueue: Permission denied"))
|
||||
|
||||
cs := &captureStore{queued: []store.QueuedDelivery{
|
||||
{QueueID: "GONEQ", To: "lost@example.net"},
|
||||
}}
|
||||
reconcile(cs, time.Now().UTC())
|
||||
|
||||
if got := cs.snapshot(); len(got) != 0 {
|
||||
t.Fatalf("got %v, want no row touched", got)
|
||||
}
|
||||
}
|
||||
|
||||
// With nothing stale to explain there is no reason to shell out to postqueue at
|
||||
// all — which is the normal state of a relay that is keeping up.
|
||||
func TestReconcileSkipsTheQueueWhenNoRowIsStuck(t *testing.T) {
|
||||
calls := stubQueue(t, nil)
|
||||
|
||||
reconcile(&captureStore{}, time.Now().UTC())
|
||||
|
||||
if *calls != 0 {
|
||||
t.Fatalf("queue listed %d time(s), want none", *calls)
|
||||
}
|
||||
}
|
||||
|
||||
// A delivery's page shows what Postfix wrote about that one message, so the
|
||||
// read has to pick its queue-id's lines out of everything else the mail path
|
||||
// logged around them — and only its own: queue ids are hexadecimal runs, and a
|
||||
// shorter one is regularly the tail of a longer one.
|
||||
func TestQueueLinesPicksOutOneMessage(t *testing.T) {
|
||||
path := writeLog(t,
|
||||
"host postfix/smtpd[20]: 41E862C00D9E: client=mail.example.com[203.0.113.4]",
|
||||
"host postfix/qmgr[10]: 5900C2C00D9E: from=<other@example.com>, size=500, nrcpt=1 (queue active)",
|
||||
"host postfix/cleanup[15]: 41E862C00D9E: message-id=<abc@example.com>",
|
||||
// Same run of characters, longer id: not this message.
|
||||
"host postfix/smtp[26]: FF41E862C00D9E: to=<z@example.net>, status=sent (250 OK)",
|
||||
"host opendkim[30]: 41E862C00D9E: DKIM-Signature field added (s=mail d=example.com)",
|
||||
"host postfix/smtp[26]: 41E862C00D9E: to=<a@example.net>, relay=mx.example.net[203.0.113.9]:25, dsn=2.0.0, status=sent (250 OK)",
|
||||
)
|
||||
|
||||
lines, err := QueueLines(path, "41E862C00D9E", 200)
|
||||
if err != nil {
|
||||
t.Fatalf("QueueLines: %v", err)
|
||||
}
|
||||
if len(lines) != 4 {
|
||||
t.Fatalf("got %d lines, want 4:\n%s", len(lines), strings.Join(lines, "\n"))
|
||||
}
|
||||
// Oldest first: the page reads the message's history downwards.
|
||||
if !strings.Contains(lines[0], "client=") || !strings.Contains(lines[3], "status=sent") {
|
||||
t.Errorf("lines are not in the order they were logged:\n%s", strings.Join(lines, "\n"))
|
||||
}
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, "FF41E862C00D9E") || strings.Contains(line, "5900C2C00D9E") {
|
||||
t.Errorf("another message's line came back: %q", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A message the milter refused has no queue id, so there is nothing to match
|
||||
// on — every line in the log would be someone else's.
|
||||
func TestQueueLinesWithoutAQueueIDMatchesNothing(t *testing.T) {
|
||||
path := writeLog(t, "host postfix/smtp[26]: 41E862C00D9E: to=<a@example.net>, status=sent (250 OK)")
|
||||
|
||||
lines, err := QueueLines(path, "", 200)
|
||||
if err != nil {
|
||||
t.Fatalf("QueueLines: %v", err)
|
||||
}
|
||||
if lines != nil {
|
||||
t.Errorf("got %v, want no lines", lines)
|
||||
}
|
||||
}
|
||||
|
||||
// The cap keeps the newest lines, not the first ones: what a message did last
|
||||
// is what its page is opened for.
|
||||
func TestQueueLinesCapKeepsTheLatest(t *testing.T) {
|
||||
var log []string
|
||||
for i := 0; i < 10; i++ {
|
||||
log = append(log, "host postfix/smtp[26]: ABC123: attempt "+itoa(i))
|
||||
}
|
||||
path := writeLog(t, log...)
|
||||
|
||||
lines, err := QueueLines(path, "ABC123", 3)
|
||||
if err != nil {
|
||||
t.Fatalf("QueueLines: %v", err)
|
||||
}
|
||||
if len(lines) != 3 || !strings.HasSuffix(lines[0], "attempt 7") || !strings.HasSuffix(lines[2], "attempt 9") {
|
||||
t.Errorf("cap did not keep the last three:\n%s", strings.Join(lines, "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
// Send-log rows outlive mail.log — retention is ninety days and rotation keeps
|
||||
// fourteen files — so a message whose lines are gone, or a log that is between
|
||||
// rotations and absent altogether, is an empty answer for the page to explain,
|
||||
// not an error for it to report.
|
||||
func TestQueueLinesOnAMessageWithNoLinesLeft(t *testing.T) {
|
||||
path := writeLog(t, "host postfix/smtp[26]: 5900C2C00D9E: to=<z@example.net>, status=sent (250 OK)")
|
||||
|
||||
lines, err := QueueLines(path, "41E862C00D9E", 200)
|
||||
if err != nil {
|
||||
t.Fatalf("QueueLines: %v", err)
|
||||
}
|
||||
if lines != nil {
|
||||
t.Errorf("got %v, want no lines", lines)
|
||||
}
|
||||
}
|
||||
|
||||
// writeLog creates a mail.log holding the given lines and returns its path.
|
||||
func writeLog(t *testing.T, lines ...string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "mail.log")
|
||||
body := ""
|
||||
for _, line := range lines {
|
||||
body += line + "\n"
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatalf("write log: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func itoa(n int) string { return strconv.Itoa(n) }
|
||||
|
||||
// The read is bounded, so on a log that has grown all day it starts in the
|
||||
// middle of a line. That fragment is not a line and must not come back as one,
|
||||
// and anything before the budget is out of reach — which the page reports the
|
||||
// same way as a message whose lines have rotated away.
|
||||
func TestQueueLinesReadsABoundedTail(t *testing.T) {
|
||||
old := queueScanBytes
|
||||
queueScanBytes = 120
|
||||
t.Cleanup(func() { queueScanBytes = old })
|
||||
|
||||
path := writeLog(t,
|
||||
"host postfix/smtp[26]: ABC123: too far back to reach, padded out past the budget with this run of filler text",
|
||||
"host postfix/smtp[26]: ABC123: within the budget",
|
||||
)
|
||||
|
||||
lines, err := QueueLines(path, "ABC123", 200)
|
||||
if err != nil {
|
||||
t.Fatalf("QueueLines: %v", err)
|
||||
}
|
||||
if len(lines) != 1 || !strings.HasSuffix(lines[0], "within the budget") {
|
||||
t.Errorf("got %d lines, want only the one inside the budget:\n%s", len(lines), strings.Join(lines, "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
// The delivery page shows a message's lines as a table of when and what, so the
|
||||
// timestamp has to come off the head of the line — in either of the two formats
|
||||
// a mail log arrives in — and nothing may be lost doing it.
|
||||
func TestSplitTimestamp(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
line string
|
||||
stamp, rest string
|
||||
}{
|
||||
{
|
||||
name: "RFC 3339, which maillog_file_format selects on Postfix 3.9 and up",
|
||||
line: "2026-08-03T05:15:52.219218+00:00 mail postfix/smtp[26]: 4A1B2C3D: to=<a@example.net>, status=sent (250 OK)",
|
||||
stamp: "2026-08-03 05:15:52",
|
||||
rest: "mail postfix/smtp[26]: 4A1B2C3D: to=<a@example.net>, status=sent (250 OK)",
|
||||
},
|
||||
{
|
||||
name: "no fractional seconds, zone as Z",
|
||||
line: "2026-08-03T05:15:52Z mail postfix/qmgr[10]: 4A1B2C3D: removed",
|
||||
stamp: "2026-08-03 05:15:52",
|
||||
rest: "mail postfix/qmgr[10]: 4A1B2C3D: removed",
|
||||
},
|
||||
{
|
||||
name: "no zone at all",
|
||||
line: "2026-08-03T05:15:52 mail opendkim[30]: 4A1B2C3D: DKIM-Signature field added",
|
||||
stamp: "2026-08-03 05:15:52",
|
||||
rest: "mail opendkim[30]: 4A1B2C3D: DKIM-Signature field added",
|
||||
},
|
||||
{
|
||||
name: "syslog's traditional format, space-padded day",
|
||||
line: "Aug 3 05:15:52 mail postfix/smtpd[20]: 4A1B2C3D: client=app.example.ru[203.0.113.4]",
|
||||
stamp: "Aug 3 05:15:52",
|
||||
rest: "mail postfix/smtpd[20]: 4A1B2C3D: client=app.example.ru[203.0.113.4]",
|
||||
},
|
||||
{
|
||||
// Copied off the live relay (Postfix 3.7, which has no
|
||||
// maillog_file_format), so this is the shape the panel actually
|
||||
// meets: zero-padded day, and the host is the container's name.
|
||||
name: "syslog's traditional format as the live relay writes it",
|
||||
line: "Aug 08 07:26:41 selfpost postfix/master[231]: daemon started -- version 3.7.11, configuration /etc/postfix",
|
||||
stamp: "Aug 08 07:26:41",
|
||||
rest: "selfpost postfix/master[231]: daemon started -- version 3.7.11, configuration /etc/postfix",
|
||||
},
|
||||
{
|
||||
name: "unrecognised head keeps the whole line",
|
||||
line: "mail postfix/smtp[26]: 4A1B2C3D: to=<a@example.net>, status=sent (250 OK)",
|
||||
stamp: "",
|
||||
rest: "mail postfix/smtp[26]: 4A1B2C3D: to=<a@example.net>, status=sent (250 OK)",
|
||||
},
|
||||
{
|
||||
// A date-like run that is not the head of the line is not a stamp.
|
||||
name: "date inside the text is left alone",
|
||||
line: "mail postfix/smtp[26]: ABC: 220 mx.example.net ready at 2026-08-03T05:15:52+00:00",
|
||||
stamp: "",
|
||||
rest: "mail postfix/smtp[26]: ABC: 220 mx.example.net ready at 2026-08-03T05:15:52+00:00",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
stamp, rest := SplitTimestamp(c.line)
|
||||
if stamp != c.stamp || rest != c.rest {
|
||||
t.Errorf("SplitTimestamp(%q) = (%q, %q), want (%q, %q)", c.line, stamp, rest, c.stamp, c.rest)
|
||||
}
|
||||
// Whatever the split, the line's own text survives it whole.
|
||||
if !strings.Contains(c.line, rest) {
|
||||
t.Errorf("the text column is not part of the line it came from: %q", rest)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user