feat: log-tailer offset persistence + in-flight L2 rate-limit accounting (code-review.md § Phase 3)

- logtail: persist the read position (offset + fingerprint of the log's
  first 512 bytes) in a new logtail_state table (migration 0003) and
  resume from it on start, so delivery lines written while the panel was
  down are parsed instead of skipped and their send-log rows no longer
  stay "queued" forever. Fingerprint mismatch (rotated/recreated while
  down) reads the file from the start — re-parsing is idempotent; a
  first-ever start with nothing stored still begins at end-of-file.
  Writes are throttled to one per 5s, forced on rotation and shutdown.

- milter: count messages that passed the level-2 check but have not
  reached the send log yet (internal/milter/inflight.go), so concurrent
  SMTP sessions cannot each spend the same last slot. A literal
  count+insert transaction, as the review suggested, is not possible:
  the count happens at MAIL FROM and the insert at end-of-message.
  Reservations are released after the insert, on ABORT, and after a
  10-minute TTL — a client that drops mid-transaction must not be able
  to hold a slot, since the limiter is fail-open by design.

Docs: architecture.md (log tailer, persistence, L2 counting),
security.md and roadmap.md (restart gap closed, container recreate
remains), CHANGELOG, progress.md, code-review.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 17:12:37 +03:00
parent 4b0f537508
commit ed0a786739
15 changed files with 683 additions and 40 deletions
+25 -5
View File
@@ -45,13 +45,18 @@ type Store interface {
type session struct {
milter.NoOpMilter
rec Store
// flight is shared by every session of the process; it holds the messages
// that passed the level-2 check but are not in the send log yet. Nil is a
// valid zero value (no in-flight accounting).
flight *inflight
clientIP string // captured once per connection
login string
from string
rcpts []string
subject string
login string
from string
rcpts []string
subject string
reserved []*reservation // level-2 slots held by the current message
}
// Connect captures the client IP, which comes from the addr parameter rather
@@ -72,6 +77,7 @@ func (s *session) Connect(host, family string, port uint16, addr net.IP, m *milt
// tempfail before recipients are even offered (spec 7.4). Enforcement is
// fail-open — see overLimit.
func (s *session) MailFrom(from string, m *milter.Modifier) (milter.Response, error) {
s.releaseReservations() // a previous transaction that ended without EOM/ABORT
s.from = cleanAddress(from)
s.login = macro(m, "auth_authen")
s.rcpts = nil
@@ -127,9 +133,22 @@ func decodeSubject(v string) string {
// rows are written. We accept (this milter is done) without ever rejecting.
func (s *session) Body(m *milter.Modifier) (milter.Response, error) {
s.record(macro(m, "i"))
// The rows are in the send log now, so the stored count sees this message
// and its level-2 slots are no longer needed.
s.releaseReservations()
return milter.RespAccept, nil
}
// Abort ends the current transaction without an end-of-message (client RSET, or
// Postfix rejecting the message for its own reasons). No send-log row will be
// written, so the level-2 slots this message held must go back.
func (s *session) Abort(m *milter.Modifier) error {
s.releaseReservations()
s.rcpts = nil
s.subject = ""
return nil
}
// macro reads a milter macro, tolerating Postfix's convention of wrapping
// multi-character macro names in curly braces (e.g. {auth_authen}) while
// single-character names (e.g. i) arrive bare. go-milter stores whatever name
@@ -191,8 +210,9 @@ func domainOf(addr string) string {
// Serve runs the journal-milter on ln until ctx is cancelled. Each connection
// gets a fresh session bound to rec. It returns nil on a clean shutdown.
func Serve(ctx context.Context, ln net.Listener, rec Store) error {
flight := &inflight{} // shared: the level-2 window spans all connections
srv := &milter.Server{
NewMilter: func() milter.Milter { return &session{rec: rec} },
NewMilter: func() milter.Milter { return &session{rec: rec, flight: flight} },
Actions: 0, // read-only: we make no message modifications
Protocol: milter.OptNoBody, // the journal needs headers/EOM, not the body
}