feat: implement B.2 — rotate mail.log by rename + postfix reload

Replaces copytruncate with rename + `postfix reload` (the same mechanism
`postfix logrotate` itself uses), closing the up-to-one-second window where
copytruncate could drop in-flight delivery lines and leave a send-log row
stuck at "queued" forever.

logrotate-mail.conf keeps `create 0644 root root` rather than `nocreate` as
originally planned: verified on a live container that Postfix recreates the
file itself only lazily, on the next write after reload, and at mode 0600 —
unreadable by the unprivileged panel process. `create` hands the file back at
0644 immediately after rename, before Postfix ever touches it.

logtail.follow() re-drains the old file descriptor once more right before
switching to the rotated file, closing the residual gap between the last
poll's drain and the rotation check. readLogTail() treats a momentarily
missing mail.log as an empty screen rather than a logged error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-02 23:45:15 +03:00
parent 538a4b6603
commit db1572d7ad
8 changed files with 64 additions and 13 deletions
+8
View File
@@ -1,6 +1,8 @@
package web
import (
"errors"
"io/fs"
"net/http"
"strconv"
@@ -164,6 +166,12 @@ func (s *Server) handleLogTailBody(w http.ResponseWriter, r *http.Request) {
func (s *Server) readLogTail() ([]string, string) {
lines, err := logtail.TailLines(s.cfg.MailLogPath, logTailLines)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
// Rotation renamed the file away; Postfix recreates it on reload
// (within about a second), so this is a normal, brief gap rather
// than a failure worth alarming the operator about.
return nil, ""
}
logf("panel: tail %s: %v", s.cfg.MailLogPath, err)
return nil, "Could not read the mail log."
}