Agreed by the owner on 2026-10-13 (the local day, no "UTC" beside a
time); the zone is the container's TZ in .env and Settings only shows it,
his "Да" of 2026-10-17. Mockups c205106.
Every time the panel shows is formatted in time.Local and says no zone:
the logs' Time and the DMARC Received columns, a message's timeline,
Overview's "Updated", Health's certificate and renewal, the filter lists,
DMARC reports and windows, the auto limit's last recalculation. Stored
instants stay UTC. The days counted are the zone's: delivery_log_days and
sender_check_days key their rows by the local date, the sender check cuts
downtime at local midnights (byDay), the log filters' days run from local
midnight to local midnight. Notification mail and the test message write
the zone's abbreviation after a time - they are read away from the panel.
DMARC's one-day window label stays the reporter's (UTC) day.
Settings box 05, Time zone, read-only: the name (UTC without TZ), its
offset, and a TZ the image cannot load said to leave the times in UTC
(time/zoneinfo_unix.go, initLocal) rather than named. TZ is passed by the
compose file ("${TZ:-}"), in .env.example and the guide's table; tzdata is
named in the image (supervisor's python3.13 brings it today). Postfix
takes TZ through import_environment, so mail.log's stamps are the same
zone, which closes the trap the roadmap wrote down: the tailer has always
read them in time.Local.
Tests: a TestMain runs view, handlers, store and cmd/panel in UTC whatever
the machine's zone (as logtail's); TestSettingsShowsTheTimeZone,
TestTimesAreTheContainersZone, TestByDayInTheContainersZone, a Moscow day
in TestQueryInboundSpamFilters; envdoc's pattern takes two-letter names.
e2e time_zone_is_the_containers recreates the stand with
TZ=Europe/Moscow: date says MSK, Settings names the zone and UTC+03:00, a
test message's page gives its time and its mail.log lines in Moscow's.
go vet + go test (26 packages) and make e2e on the test server for this
tree.
Docs: guide (§ Settings, the TZ row, the days of Health and the logs),
architecture, schema-migrations, help (word for word with the mockup),
CHANGELOG [Unreleased] § Added, roadmap row done with the decision.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
214 lines
7.8 KiB
Go
214 lines
7.8 KiB
Go
package store
|
|
|
|
import (
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// Why a send-log row with status "rejected" was refused (send_log.reason).
|
|
// A row written before the reason was recorded has "".
|
|
const (
|
|
ReasonLimit = "limit" // a level-2 rate limit
|
|
ReasonClient = "client" // the application's list of client addresses
|
|
ReasonFrom = "from" // the From: line is not the application's
|
|
ReasonSender = "sender" // the Sender: line is not the application's (2.8.0)
|
|
)
|
|
|
|
// Who decided a "bounced" row the log did not (send_log.reason).
|
|
const (
|
|
// ReasonReconcile: the reconcile sweep closed the row - its message left
|
|
// Postfix's queue with no delivery line ever read for it.
|
|
ReasonReconcile = "reconcile"
|
|
// ReasonReconcileContradicted: the sweep closed it and a delivery line
|
|
// read afterwards gave the status the row now has.
|
|
ReasonReconcileContradicted = "reconcile-contradicted"
|
|
)
|
|
|
|
// Settings keys of the sender check's statistics.
|
|
const (
|
|
// settingSenderCheckSince is the first day (of the container's zone) the statistics cover.
|
|
settingSenderCheckSince = "sender_check_since"
|
|
// settingSenderCheckDeferredAt is when a message was last deferred because
|
|
// the check did not answer (RFC3339 UTC).
|
|
settingSenderCheckDeferredAt = "sender_check_deferred_at"
|
|
// settingSenderCheckCountedPlace is the place in mail.log of the last
|
|
// warning counted ("<fingerprint>@<offset>"), saved with the count.
|
|
settingSenderCheckCountedPlace = "sender_check_counted_place"
|
|
)
|
|
|
|
// senderCheckKeepDays is how long a day of the statistics is kept.
|
|
const senderCheckKeepDays = 366
|
|
|
|
const dayLayout = "2006-01-02"
|
|
|
|
// SenderCheckTotals is what the strict sender check has cost over a period.
|
|
type SenderCheckTotals struct {
|
|
Down time.Duration // the panel not answering while Postfix was running
|
|
Deferred int64 // connections Postfix turned away because of it
|
|
Messages int64 // messages deferred in the middle because of it
|
|
// Estimated: a day of the period had its downtime taken from the alive
|
|
// mark alone - at most what it was.
|
|
Estimated bool
|
|
}
|
|
|
|
// SenderCheckStats is the card of Server > Health.
|
|
type SenderCheckStats struct {
|
|
Today, Month, Year SenderCheckTotals
|
|
// Since is the first day counted; zero before anything was.
|
|
Since time.Time
|
|
// DeferredAt is when a message was last deferred; zero if never.
|
|
DeferredAt time.Time
|
|
}
|
|
|
|
// StartSenderCheckStats records the first day the statistics cover, once.
|
|
func (s *Store) StartSenderCheckStats(now time.Time) error {
|
|
v, err := s.GetSetting(settingSenderCheckSince)
|
|
if err != nil || v != "" {
|
|
return err
|
|
}
|
|
return s.SetSetting(settingSenderCheckSince, now.Local().Format(dayLayout))
|
|
}
|
|
|
|
// AddSenderCheckDown adds to the day of at the time the panel did not answer
|
|
// while Postfix was running; estimated marks the day when that time is an
|
|
// upper bound (supervisord could not say since when Postfix ran).
|
|
func (s *Store) AddSenderCheckDown(at time.Time, d time.Duration, estimated bool) error {
|
|
secs := int64(d / time.Second)
|
|
if secs <= 0 {
|
|
return nil
|
|
}
|
|
_, err := s.db.Exec(
|
|
`INSERT INTO sender_check_days (day, down_seconds, estimated) VALUES (?, ?, ?)
|
|
ON CONFLICT (day) DO UPDATE SET down_seconds = down_seconds + excluded.down_seconds,
|
|
estimated = MAX(estimated, excluded.estimated)`,
|
|
at.Local().Format(dayLayout), secs, estimated,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("sender check downtime: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AddSenderCheckDeferred counts, on the day of at, n messages deferred because
|
|
// the check did not answer, remembers at as the last such moment, and saves
|
|
// place - where in mail.log the counted line ends - in the same transaction:
|
|
// a count without its place, or the other way round, would be counted again
|
|
// or lost at the next start (audit 2026-10-12, stage 2b).
|
|
//
|
|
// message says which of the two it is: a message deferred in the middle
|
|
// (cleanup's warning) rather than a connection turned away (smtpd's).
|
|
func (s *Store) AddSenderCheckDeferred(at time.Time, n int64, place string, message bool) error {
|
|
if n <= 0 {
|
|
return nil
|
|
}
|
|
// The first statement is a write (see UpdateStatus).
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return fmt.Errorf("sender check deferred: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
column := "deferred"
|
|
if message {
|
|
column = "messages"
|
|
}
|
|
if _, err := tx.Exec(fmt.Sprintf(
|
|
`INSERT INTO sender_check_days (day, %[1]s) VALUES (?, ?)
|
|
ON CONFLICT (day) DO UPDATE SET %[1]s = %[1]s + excluded.%[1]s`, column),
|
|
at.Local().Format(dayLayout), n,
|
|
); err != nil {
|
|
return fmt.Errorf("sender check deferred: %w", err)
|
|
}
|
|
// To the fraction of a second the log gives, and of a fixed width so that
|
|
// the strings compare as the times do; a re-read log must not move the
|
|
// mark back. SQLite 3.53.4, https://sqlite.org/lang_upsert.html § 2: a
|
|
// DO UPDATE whose WHERE is false is a no-op, not an error, and excluded.
|
|
// names the value the INSERT brought.
|
|
stamp := at.UTC().Format("2006-01-02T15:04:05.000000Z")
|
|
setting := func(key, value, cond string) error {
|
|
_, err := tx.Exec(`INSERT INTO settings (key, value) VALUES (?, ?)
|
|
ON CONFLICT (key) DO UPDATE SET value = excluded.value`+cond, key, value)
|
|
return err
|
|
}
|
|
if err := setting(settingSenderCheckDeferredAt, stamp, " WHERE excluded.value > settings.value"); err != nil {
|
|
return fmt.Errorf("sender check deferred: %w", err)
|
|
}
|
|
if place != "" {
|
|
if err := setting(settingSenderCheckCountedPlace, place, ""); err != nil {
|
|
return fmt.Errorf("sender check deferred: %w", err)
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("sender check deferred: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SenderCheckCountedPlace is the place in mail.log of the last warning
|
|
// counted; "" before any was.
|
|
func (s *Store) SenderCheckCountedPlace() (string, error) {
|
|
return s.GetSetting(settingSenderCheckCountedPlace)
|
|
}
|
|
|
|
// SenderCheckDeferredAt is when a message was last counted as deferred; zero
|
|
// if none was.
|
|
func (s *Store) SenderCheckDeferredAt() (time.Time, error) {
|
|
v, err := s.GetSetting(settingSenderCheckDeferredAt)
|
|
if err != nil || v == "" {
|
|
return time.Time{}, err
|
|
}
|
|
at, _ := time.Parse(time.RFC3339Nano, v)
|
|
return at, nil
|
|
}
|
|
|
|
// SenderCheckStats sums the days up to now: today, the last 30 days and the
|
|
// last 12 months, each including today.
|
|
func (s *Store) SenderCheckStats(now time.Time) (SenderCheckStats, error) {
|
|
now = now.Local()
|
|
var out SenderCheckStats
|
|
sum := func(days int) (SenderCheckTotals, error) {
|
|
var secs, deferred, messages, estimated int64
|
|
err := s.db.QueryRow(
|
|
`SELECT COALESCE(SUM(down_seconds), 0), COALESCE(SUM(deferred), 0), COALESCE(SUM(messages), 0),
|
|
COALESCE(MAX(estimated), 0)
|
|
FROM sender_check_days WHERE day > ? AND day <= ?`,
|
|
now.AddDate(0, 0, -days).Format(dayLayout), now.Format(dayLayout),
|
|
).Scan(&secs, &deferred, &messages, &estimated)
|
|
if err != nil {
|
|
return SenderCheckTotals{}, fmt.Errorf("sender check stats: %w", err)
|
|
}
|
|
return SenderCheckTotals{Down: time.Duration(secs) * time.Second, Deferred: deferred,
|
|
Messages: messages, Estimated: estimated > 0}, nil
|
|
}
|
|
var err error
|
|
if out.Today, err = sum(1); err != nil {
|
|
return out, err
|
|
}
|
|
if out.Month, err = sum(30); err != nil {
|
|
return out, err
|
|
}
|
|
if out.Year, err = sum(365); err != nil {
|
|
return out, err
|
|
}
|
|
since, err := s.GetSetting(settingSenderCheckSince)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
out.Since, _ = time.ParseInLocation(dayLayout, since, time.Local)
|
|
deferredAt, err := s.GetSetting(settingSenderCheckDeferredAt)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
out.DeferredAt, _ = time.Parse(time.RFC3339, deferredAt)
|
|
return out, nil
|
|
}
|
|
|
|
// PruneSenderCheckDays deletes the days older than a year.
|
|
func (s *Store) PruneSenderCheckDays(now time.Time) error {
|
|
_, err := s.db.Exec(`DELETE FROM sender_check_days WHERE day < ?`,
|
|
now.Local().AddDate(0, 0, -senderCheckKeepDays).Format(dayLayout))
|
|
if err != nil {
|
|
return fmt.Errorf("prune sender check days: %w", err)
|
|
}
|
|
return nil
|
|
}
|