Files
selfpost/internal/logtail/logtail.go
T
mixandClaude Opus 5.5 9eddd49602
test / test (push) Canceled after 0s
audit 2c: findings of the two reviews
Technical review (Opus, 11 findings, 2 medium) and security review (Fable,
no high or medium) of 3c86280..HEAD. Both confirm the port gives
dkim_mail_parse()'s result on every input it parses, and that no line the
check passes is signed with another domain's key.

- F1 (medium): OpenDKIM's parser takes lines no mail program reads as one
  address - "ceo@bank.com:me@mine.com", "Name <ceo@bank.com:me@mine.com>",
  "<b@evil.com, a@mine.com>", "ceo@bank.com <me@mine.com>",
  "ceo@bank.com@mine.com" - and signs for the last while a lenient reader
  shows the first. The line must now also be one address in RFC 5322's
  grammar (net/mail, display names in any charset passed through), at the
  domain OpenDKIM signs for. A quoted display name may still hold anything.
- F2 (medium): the bindings were asked about OpenDKIM's form of the
  address, quotes and all, so "x"@mine.com passed for an application
  holding @mine.com while x@mine.com belonged to another. They are now
  asked about the reader's address, its local part unquoted, as Postfix
  holds the envelope's.
- F3 / security (low): Bindings.Allowed and DomainOf fold case in ASCII
  only: Postfix folds as Unicode (casefold.c, ICU), but every key and
  login the panel writes is ASCII, and the Unicode fold let the Kelvin sign
  pass for "k" (net/mail then refuses such a line on the domain test).
- F4 (low): a TERM between "postfix start-fg &" and "child=$!" is no longer
  lost: the wrapper stops Postfix as soon as the pid is known.
- F5 (low): for startsecs after a start any failed reload passed as "not
  running". postfix-reload is now build/postfix-reload.sh: exit 75 when
  master -t says no master runs, else exec postfix reload; RunOnce returns
  EndError with the status, and only 75 is taken as done. supervisor.State
  goes.
- F6 (low): crashexit spares postfix-reload too - a reload that could not
  be spawned is reported to whoever asked, not a reason to stop the
  container.
- F7 (low): a certs/ that is itself a link is resolved before the walk
  (filepath.WalkDir does not follow its root).
- F8 (low): at shutdown the tailer reads what is left of the rotated
  file before saving its place. Not changed: a refusal line still waiting
  on the old file at shutdown is not kept - the panel writes refused rows
  itself, only a kill loses one, and that window is the minute's grace.
- F9 (low): pct read as text - empty or unreadable is 100, the rest held
  to 0..100.
- F10 (low): the directory is synced after sasldb2's rename.
- F11 (low): POSTFIX_STARTSECS by expr, so "010" or "08" in
  MILTER_WAIT_TIMEOUT are decimal, not octal or an error.
- Security (low): pagehide also empties defaultValue - the input's value
  attribute and the textarea's text - not only .value. A sasldb2.new left
  by a killed panel is removed at start (entrypoint) and never archived.
  security.md says that links under certs/ are followed and by whom they
  can be placed.

Tests: sender check cases for F1 (four lines), F2 (a bound address
quoted, for its owner and for another), F3; EndError's state and status;
pct cases. architecture.md and CHANGELOG describe the reader rule. Stand
(2026-10-15, make e2e, exit 0); go vet and go test ./... pass on the
server (24 packages).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:17:33 +03:00

884 lines
31 KiB
Go

// Package logtail follows Postfix's mail.log and reconciles the send-log
// delivery statuses the journal-milter could not know at receive time
// (architecture.md § Persistence). A milter row starts life as "queued";
// Postfix only decides sent / deferred / bounced later, per recipient, and
// reports it in mail.log. This package parses those lines by queue-id +
// recipient and advances the matching rows, and prunes rows past the retention
// window. It also keeps every line Postfix wrote under a queue id, so that a
// message's page can show them after the log has rotated (lines.go).
package logtail
import (
"bufio"
"bytes"
"context"
"io"
"log"
"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, finding the rows still waiting for one, pruning the
// retention window, keeping the lines of queued messages, and remembering how
// far into mail.log it has read. *store.Store satisfies it.
type StatusStore interface {
UpdateStatus(queueID, recipient, status string) (int64, error)
ExpireMessage(queueID string) (int64, error)
ListOpenOlderThan(cutoff time.Time) ([]store.QueuedDelivery, error)
CloseUnreported(id int64) (int64, error)
// The counters of delivery_log_days (store/deliverylog.go).
AddReconcileClosed(at time.Time, n int64) error
AddReconcileMissed(at time.Time, n int64) error
AddTailerBlind(from time.Time, d time.Duration) error
CountOpenOlderThan(cutoff time.Time) (int64, error)
SetQueuedOverLifetime(at time.Time, n int64) error
PruneDeliveryLogDays(now time.Time) error
RefusalStore
DeleteSendLogBefore(cutoff time.Time) (int64, error)
InsertSendLogLines(lines []store.SendLogLine, once bool) (int, error)
PruneSendLogLines(cutoff time.Time) (int64, error)
LogtailState(path string) (store.LogtailState, bool, error)
SaveLogtailState(path string, st store.LogtailState) error
}
// pollInterval is how often the tail loop checks for new bytes / rotation. It
// is a var so tests can shorten it.
var pollInterval = time.Second
// retentionInterval is how often the retention sweep runs (also once at
// startup). It is a var so tests can shorten it.
var retentionInterval = 6 * time.Hour
// RetentionInterval returns how often background send-log pruning runs.
func RetentionInterval() time.Duration {
return retentionInterval
}
// queueIDs lists the messages Postfix currently holds, for the reconcile sweep.
// It is a var so tests can answer without a running Postfix. Postfix 3.7.11,
// src/showq/showq.c, showq_service(): the listing covers the maildrop,
// active, incoming, deferred and hold queues.
var queueIDs = postfix.QueueIDs
// retryPolicy reads Postfix's queue lifetimes, for counting the rows left
// queued past them. A var for the same reason.
var retryPolicy = postfix.LoadRetryPolicy
const (
// 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
)
// RetentionDays returns the send-log retention window in days. The log-tailer
// calls it on every prune cycle so a panel change takes effect without restart.
type RetentionDays func() int
// deliveryRe matches a Postfix delivery line and captures queue-id, recipient
// and status, e.g.
//
// postfix/smtp[26]: 41E862C00D9E: to=<a@example.net>, relay=…, dsn=2.0.0, status=sent (250 OK)
//
// The "<queue-id>: to=<addr>, …, status=<word>" shape is the per-recipient
// result line: Postfix 3.7.11, src/global/log_adhoc.c, log_adhoc() writes
// "%s: to=<%s>", ", orig_to=<%s>" (only when the address was rewritten),
// ", relay=%s", then ", dsn=%s, status=%s (%s)". It is called from sent(),
// defer_append() and bounce_append() in whichever process decides - the
// delivery agents, and qmgr too for recipients it defers or bounces itself
// (src/qmgr/qmgr_defer.c, qmgr_bounce.c); smtpd's and cleanup's own lines
// have no "to=<…>, relay=" after the id.
//
// The recipient runs to the ">, " that comes before relay= (and orig_to=,
// when there is one): a quoted local part may hold a ">", and a recipient cut
// there would match no row and leave it for the sweep (review of stage 1).
//
// It is matched at the head of the line - the host name, "program[pid]:",
// then the queue id - after SplitTimestamp has taken the stamp off, like
// queueLineRe and the inbound patterns. Anywhere else the text is a
// stranger's: a port-25 reject line ends with the client's HELO name, which
// once parsed as a delivery result when it read "<id>: to=<…>, status=sent"
// (audit 2026-10-12, M12).
//
// The run before status= is lazy on purpose. Postfix appends the remote
// server's reply verbatim, so a greedy match would take the *last* status= on
// the line — and that one can come from the reply text, which the far end
// controls. A bounce whose reply quoted "status=sent" would then be filed as a
// success. The real field is always the first one after to=<…>: in
// log_adhoc() the reply is the "(%s)" after status=.
var deliveryRe = regexp.MustCompile(`^\S+ [^\s\[]+\[\d+\]: ([0-9A-Za-z]+): to=<(.*?)>, (?:orig_to=<(.*?)>, )?relay=.*?\bstatus=(\w+)`)
// parseDelivery extracts (queue-id, recipient, status) from a mail.log line.
// ok is false for lines that are not recognised delivery results. The
// recipient is the one the application gave: orig_to= when Postfix rewrote
// the address (log_adhoc() writes it only then), to= otherwise - the row
// holds the RCPT TO address (assumptions A-9).
func parseDelivery(line string) (queueID, recipient, status string, ok bool) {
_, rest := SplitTimestamp(line)
m := deliveryRe.FindStringSubmatch(rest)
if m == nil {
return "", "", "", false
}
recipient = m[2]
if m[3] != "" {
recipient = m[3]
}
switch m[4] {
case "sent":
status = store.StatusSent
case "deferred":
status = store.StatusDeferred
case "bounced":
status = store.StatusBounced
default:
return "", "", "", false
}
return m[1], recipient, status, true
}
// expiryRe matches qmgr's line for a message it gave up on:
//
// postfix/qmgr[93]: 41E862C00D9E: from=<app@example.org>, status=expired, returned to sender
//
// Postfix 3.7.11, src/qmgr/qmgr_active.c, qmgr_active_done_25_generic():
// msg_info("%s: from=<%s>, status=%s, returned to sender") with "expired"
// once the queue lifetime has passed, "force-expired" after postsuper -e.
// It is the only line about the expiry, one per message, and no recipient is
// named (assumptions A-8). The line must end there: the sender is the
// client's text, and only qmgr writes this shape.
var expiryRe = regexp.MustCompile(`^\S+ \S*/qmgr\[\d+\]: ([0-9A-Za-z]+): from=<.*>, status=(?:force-)?expired, returned to sender\s*$`)
// parseExpiry returns the queue id of a message Postfix gave up on, from its
// expiry line; ok is false for any other line.
func parseExpiry(line string) (queueID string, ok bool) {
_, rest := SplitTimestamp(line)
m := expiryRe.FindStringSubmatch(rest)
if m == nil {
return "", false
}
return m[1], true
}
// Run follows path and updates send-log statuses until ctx is cancelled, while
// a background sweep prunes rows older than retention(). Reading resumes at
// the offset the previous run persisted, so a restart parses the delivery lines
// written while the panel was down. The lines headed by a queue id are kept
// for the message's page, except those of mail that came in on port 25
// (lines.go). With inbound set it also writes the
// inbound journal's rows for refused mail and prunes that journal
// (inbound.go), and with sender set it counts the mail Postfix deferred
// because the sender check did not answer (sendercheck.go). It returns nil on
// a clean shutdown.
func Run(ctx context.Context, path string, st StatusStore, retention RetentionDays, inbound *Inbound, sender *SenderCheck) error {
go retentionLoop(ctx, st, retention)
var journal *inboundJournal
if inbound != nil {
go inboundRetentionLoop(ctx, inbound)
journal = newInboundJournal(inbound.Store)
}
// 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, path)
var deferred *deferredCounter
if sender != nil {
deferred = newDeferredCounter(sender)
}
keeper := newLineKeeper(st)
refusals := &refusalLines{st: st}
flush := func() {
keeper.flush()
refusals.retry(time.Now().UTC())
}
tr := &tracker{st: st, path: path, hold: refusals.oldest}
return follow(ctx, path, tr, caughtUp, flush, func(line string) {
// Until the first read reaches end-of-file the lines are those written
// while the panel was down - and, after an unclean stop, some it had
// read already.
catchingUp := true
select {
case <-caughtUp:
catchingUp = false
default:
}
now := time.Now().UTC()
if journal != nil {
journal.handle(line, now, catchingUp)
}
if deferred != nil {
deferred.handle(line, now, catchingUp, tr.line)
}
keeper.add(line, now, catchingUp)
refusals.handle(line, now, Place{FP: tr.line.FP, End: tr.lineStart})
queueID, recipient, status, ok := parseDelivery(line)
if !ok {
if queueID, ok := parseExpiry(line); ok {
if _, err := st.ExpireMessage(queueID); err != nil {
log.Printf("log-tailer: expire %s: %v", queueID, err)
}
}
return
}
if _, err := st.UpdateStatus(queueID, recipient, status); err != nil {
log.Printf("log-tailer: update %s/%s -> %s: %v", queueID, recipient, status, err)
}
})
}
// 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{}, path string) {
select {
case <-ctx.Done():
return
case <-caughtUp:
}
// A message Postfix still works on stays queued until its first retry after
// the queue lifetime, which can be a whole backoff later. Postfix 3.7.11,
// src/qmgr/qmgr_active.c: expiry is decided when an attempt ends
// (qmgr_active_done_25_generic()), and a deferred message waits at most
// maximal_backoff_time (qmgr_active_done_3_generic()). The deferred queue
// is scanned only every queue_run_delay (src/qmgr/qmgr.c), which this
// bound leaves out - assumptions A-30.
policy := retryPolicy()
overLifetime := policy.LongestLifetime() + policy.MaximalBackoff
t := time.NewTicker(reconcileInterval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
now := time.Now().UTC()
reconcile(st, now.Add(-reconcileGrace), path)
countStuck(st, now, overLifetime)
}
}
}
// countStuck records how many rows are still open past Postfix's queue
// lifetime - what the log would leave unresolved if there were no sweep. Each
// sweep overwrites the day's value, so a past day keeps its last sweep's.
func countStuck(st StatusStore, now time.Time, overLifetime time.Duration) {
n, err := st.CountOpenOlderThan(now.Add(-overLifetime))
if err == nil {
err = st.SetQueuedOverLifetime(now, n)
}
if err != nil {
log.Printf("log-tailer: count rows queued past the queue lifetime: %v", err)
}
}
// reconcile marks as bounced every row still open ("queued" or "deferred")
// from before cutoff whose message Postfix no longer holds, with reason
// "reconcile". The close itself is conditional on the row still being open,
// so a final delivery line read between the listing and the close wins.
//
// 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. Postfix does leave a recipient without a result line in a few cases of
// its own - postsuper -d (src/postsuper/postsuper.c logs only "removed"), a
// recipient cleanup dropped as a duplicate (src/cleanup/
// cleanup_out_recipient.c, been_here()), one a milter deleted - assumptions
// A-31. 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.
//
// A closed row whose delivery line is in mail.log or mail.log.1 after all is
// counted as missed: the line was there and the tailer did not read it. That
// is what tells a tailer that loses lines from a log that lost them, which
// reconcile_contradicted cannot - a lost line is never read late.
func reconcile(st StatusStore, cutoff time.Time, path string) {
rows, err := st.ListOpenOlderThan(cutoff)
if err != nil {
log.Printf("log-tailer: reconcile: list open 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, missed int64
for _, row := range rows {
if _, still := held[row.QueueID]; still {
continue
}
n, err := st.CloseUnreported(row.ID)
if err != nil {
log.Printf("log-tailer: reconcile: close %s/%s: %v", row.QueueID, row.To, err)
continue
}
closed += n
if n > 0 && resultInLog(path, row) {
log.Printf("log-tailer: reconcile: %s/%s had a delivery line in the log the tailer did not read", row.QueueID, row.To)
missed++
}
}
if closed > 0 {
log.Printf("log-tailer: reconcile: closed %d row(s) Postfix no longer holds and never reported", closed)
if err := st.AddReconcileClosed(time.Now(), closed); err != nil {
log.Printf("log-tailer: reconcile: count: %v", err)
}
if err := st.AddReconcileMissed(time.Now(), missed); err != nil {
log.Printf("log-tailer: reconcile: count: %v", err)
}
}
}
// resultInLog says whether the current log or the one rotated last holds a
// final result for row - a delivery line that says sent or bounced, or the
// message's expiry line. A deferred line is not one: a deferred row has one
// by definition. Rows reach the sweep minutes after they were written, so
// the end of those two files is where such a line would be.
// logrotate 3.21.0, logrotate.c, prerotateSingleLog(): the first rotated
// name is "<log>.1", without the compression suffix under delaycompress
// (build/logrotate-mail.conf).
func resultInLog(path string, row store.QueuedDelivery) bool {
if path == "" {
return false
}
for _, p := range []string{path, path + ".1"} {
lines, err := QueueLines(p, row.QueueID, 256)
if err != nil {
continue
}
for _, line := range lines {
if q, r, s, ok := parseDelivery(line); ok && q == row.QueueID && strings.EqualFold(r, row.To) && s != store.StatusDeferred {
return true
}
if q, ok := parseExpiry(line); ok && q == row.QueueID {
return true
}
}
}
return false
}
// retentionLoop prunes expired send-log rows immediately and then periodically,
// and after each sweep the stored log lines no row carries the queue id of:
// those the deleted rows leave behind, and those that never had a row
// (lines.go).
func retentionLoop(ctx context.Context, st StatusStore, retention RetentionDays) {
prune := func() {
retentionDays := retention()
if retentionDays <= 0 {
retentionDays = store.SendLogRetentionDaysDefault
}
cutoff := time.Now().UTC().AddDate(0, 0, -retentionDays)
n, err := st.DeleteSendLogBefore(cutoff)
if err != nil {
log.Printf("log-tailer: retention prune: %v", err)
return
}
if n > 0 {
log.Printf("log-tailer: pruned %d send-log rows older than %d days", n, retentionDays)
}
}
pruneDays := func() {
if err := st.PruneDeliveryLogDays(time.Now()); err != nil {
log.Printf("log-tailer: %v", err)
}
}
prune()
pruneLines(st)
pruneDays()
t := time.NewTicker(retentionInterval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
prune()
pruneLines(st)
pruneDays()
}
}
}
// TailLines returns up to n of the most recent lines from path, for the
// panel's mail.log monitoring view (architecture.md § Panel HTTP surface). It
// is a one-shot, point-in-time read on request — unrelated to the background
// follow loop above — that reads backwards in chunks so it stays cheap against
// a multi-megabyte log rather than reading the whole file every poll.
func TailLines(path string, n int) ([]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return nil, err
}
const chunkSize = 8192
var (
buf []byte
offset = info.Size()
)
for offset > 0 && bytes.Count(buf, []byte("\n")) <= n {
size := int64(chunkSize)
if size > offset {
size = offset
}
offset -= size
chunk := make([]byte, size)
if _, err := f.ReadAt(chunk, offset); err != nil {
return nil, err
}
buf = append(chunk, buf...)
}
text := strings.TrimRight(string(buf), "\n")
if text == "" {
return nil, nil
}
lines := strings.Split(text, "\n")
if len(lines) > n {
lines = lines[len(lines)-n:]
}
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=…";
// Postfix 3.7.11 "%s: ..." in log_adhoc(), smtpd's "%s: client=", qmgr's
// "%s: removed"),
// 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'
}
// syslogStampRe is the timestamp at the head of a mail.log line (maillog_file
// in build/postfix-config.sh), the one format Postfix 3.7 writes. Postfix 3.7.11,
// src/util/msg_logger.c, msg_logger_print(): every record is stamped
// strftime("%b %d %H:%M:%S ") in the logging process's local time (the day
// zero-padded), then the first label of the host name, and postlogd writes it
// as received (src/postlogd/postlogd.c, postlogd_service()). 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. An RFC 3339 stamp once matched
// here too, for a maillog_file_format parameter said to arrive in Postfix
// 3.9; there is none in 3.7.11, nor in the 3.12 snapshot of 2026-09-25
// (proto/postconf.proto; msg_logger.c keeps the strftime) - assumptions A-12.
var 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
// is 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 := 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.
// logrotate 3.21.0, logrotate.c, rotateSingleLog(): without copytruncate the
// file is rename()d and, with create (build/logrotate-mail.conf), a new one
// made by createOutputFile(); with copytruncate it is copied and truncated.
//
// 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.
//
// flush is called at the end of every pass over the file, before the read
// position is saved: a handler that gathers lines to write them together
// writes them there, so the saved position is never ahead of what was written.
func follow(ctx context.Context, path string, tr *tracker, caughtUp chan struct{}, flush func(), handle func(string)) error {
var (
f *os.File
r *bufio.Reader
info os.FileInfo
pending string
// consumed is the offset just past what the reader has handed over,
// pending included: where a complete line ends.
consumed int64
// old is the file rotated away, still read for rotatedGrace.
old *rotatedTail
)
openAt := func(offset int64, whence int) error {
nf, err := os.Open(path)
if err != nil {
return err
}
if _, err := nf.Seek(offset, whence); err != nil {
nf.Close()
return err
}
ni, err := nf.Stat()
if err != nil {
nf.Close()
return err
}
if f != nil {
f.Close()
}
f, r, info, pending = nf, bufio.NewReader(nf), ni, ""
consumed = offset
return nil
}
// The container may start before Postfix has created mail.log; wait for it.
for {
if err := openAt(0, io.SeekStart); err == nil {
break
}
select {
case <-ctx.Done():
return nil
case <-time.After(pollInterval):
}
}
start := tr.resume(f)
if _, err := f.Seek(start, io.SeekStart); err != nil {
log.Printf("log-tailer: seek %s: %v", path, err)
start = 0
}
consumed = start
r.Reset(f) // the reader buffered from the pre-seek position
// Rotated while the panel was down: the end of the old file first.
if tr.readRotated(handle) {
flush()
}
defer func() {
if f != nil {
f.Close()
}
}()
drain := func() {
defer flush()
for {
line, err := r.ReadString('\n')
consumed += int64(len(line))
if err == io.EOF {
pending += line // hold the partial line until it completes
return
}
if err != nil {
log.Printf("log-tailer: read %s: %v", path, err)
return
}
full := pending + line
pending = ""
tr.line = Place{FP: tr.fp, End: consumed}
tr.lineStart = consumed - int64(len(full))
handle(strings.TrimRight(full, "\r\n"))
}
}
// read returns how many bytes of the open file have actually been consumed:
// the descriptor position less the partial line bufio handed back at EOF,
// which is re-read (and completed) on the next drain or the next start.
read := func() int64 {
pos, _ := f.Seek(0, io.SeekCurrent)
return pos - int64(len(pending))
}
// drainOld reads what was written to the rotated file since the switch.
// Its lines take places in that file, which the tracker never saves as
// the place to resume from: on a restart the rotated file is read from
// the saved place in the new one onward only (readRotated).
drainOld := func(now time.Time) {
if old == nil {
return
}
defer flush()
for {
line, err := old.r.ReadString('\n')
old.consumed += int64(len(line))
if err != nil {
old.pending += line
break
}
full := old.pending + line
old.pending = ""
tr.line = Place{FP: old.fp, End: old.consumed}
tr.lineStart = old.consumed - int64(len(full))
handle(strings.TrimRight(full, "\r\n"))
}
if now.After(old.until) {
old.f.Close()
old = nil
}
}
defer func() {
if old != nil {
old.f.Close()
}
}()
var once sync.Once
blind := &blindness{add: tr.st.AddTailerBlind}
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
blind.see(time.Now(), false)
// What postlogd wrote to the rotated file since the last tick:
// the next start resumes in the new file only (review of stage
// 2c, F8).
drainOld(time.Now())
tr.record(f, read(), true) // shutdown: the next start resumes here
return nil
case <-ticker.C:
drainOld(time.Now())
drain()
once.Do(func() { close(caughtUp) })
ni, err := os.Stat(path)
if err != nil {
blind.see(time.Now(), true)
continue // file briefly gone mid-rotation; try again next tick
}
pos, _ := f.Seek(0, io.SeekCurrent)
if !os.SameFile(info, ni) || ni.Size() < pos {
// Rotated away or truncated: the old (renamed) inode may have
// gained lines between the drain() above and this check, since
// Postfix keeps writing to it until it reloads. Drain it once
// more before switching, and - renamed, not truncated - go on
// reading it for rotatedGrace after the switch, for what
// postlogd writes there until the reload (assumptions A-33).
drain()
renamed := !os.SameFile(info, ni)
if old != nil {
drainOld(time.Time{}) // a second rotation within the grace
if old != nil {
old.f.Close()
old = nil
}
}
prev := &rotatedTail{f: f, r: r, pending: pending, consumed: consumed, fp: tr.fp, until: time.Now().Add(rotatedGrace)}
if renamed {
f = nil // openAt closes the file it replaces; this one is kept
}
if err := openAt(0, io.SeekStart); err != nil {
if renamed {
f, r = prev.f, prev.r
}
log.Printf("log-tailer: reopen %s: %v", path, err)
blind.see(time.Now(), true)
continue
}
if renamed {
old = prev
}
blind.see(time.Now(), false)
tr.adopt(f)
continue
}
blind.see(time.Now(), false)
tr.record(f, read(), false)
}
}
}
// rotatedTail is mail.log just rotated away, still open: postlogd writes to
// it until "postfix reload" makes it exit. Postfix 3.7.11,
// src/postlogd/postlogd.c, pre_jail_init(): maillog_file is opened once per
// postlogd process; src/master/master_service.c, master_restart_service(),
// closes the service's status pipe at a reload (master_status_cleanup()),
// and src/master/dgram_server.c, dgram_server_abort(), exits when it reads
// its end - the next postlogd opens the new file. logrotate runs the reload
// right after the rename and create (build/logrotate-mail.conf, postrotate;
// logrotate 3.21.0, logrotate.c, rotateLogSet(): the postrotate script runs
// right after rotateSingleLog() has renamed and created), so the gap is short;
// rotatedGrace is far longer than it.
type rotatedTail struct {
f *os.File
r *bufio.Reader
pending string
consumed int64
fp string
until time.Time
}
// rotatedGrace is how long the rotated file is still read after the switch.
const rotatedGrace = time.Minute
// blindness measures the stretches in which follow cannot read mail.log - the
// file gone, reopening it failing - for delivery_log_days. A stretch is
// stored when it ends, and every minute while it lasts, so a long one is not
// lost to a restart and lands on the days it covered. A stretch shorter than
// blindMin is not counted: a rotation leaves the name missing for a tick
// between rename and create, and that loses nothing - the new file is read
// from its start. logrotate 3.21.0, logrotate.c, rotateSingleLog(): the
// rename() and createOutputFile() follow each other in one pass, with no
// script or wait between them (postrotate runs after).
type blindness struct {
add func(from time.Time, d time.Duration) error
since time.Time // start of the current stretch; zero when reading
long bool // the current stretch has passed blindMin
}
const blindMin = 3 * time.Second
func (b *blindness) see(now time.Time, blind bool) {
if blind && b.since.IsZero() {
b.since = now
return
}
if b.since.IsZero() {
return
}
d := now.Sub(b.since)
if d >= blindMin {
b.long = true
}
if blind && d < time.Minute {
return
}
if b.long {
if err := b.add(b.since, d); err != nil {
log.Printf("log-tailer: count the time mail.log could not be read: %v", err)
}
}
if blind {
b.since = now // the stretch goes on; the next part starts here
return
}
b.since, b.long = time.Time{}, false
}