Files
mixandClaude Opus 5.5 bdeb01681d local-time: the panel in the container's zone, named on Settings
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>
2026-10-17 01:34:28 +03:00

122 lines
4.4 KiB
Go

package store
import (
"fmt"
"time"
)
// The counters of delivery_log_days (migration 0007): what the reconcile sweep
// and the log tailer did, a row per day of the container's zone, read off a copy of the database
// to decide the sweep's fate (audit 2026-10-12, stage 1). No page shows them.
// deliveryLogKeepDays is how long a day of the counters is kept.
const deliveryLogKeepDays = 366
// deliveryLogCounters are the columns addDeliveryLogDay may add to: the
// name is put into the statement, so nothing else may reach it.
var deliveryLogCounters = map[string]bool{
"reconcile_closed": true, "reconcile_missed": true, "tailer_blind_seconds": true,
}
// addDeliveryLogDay adds n to one counter of the day of at.
func (s *Store) addDeliveryLogDay(at time.Time, column string, n int64) error {
if !deliveryLogCounters[column] {
return fmt.Errorf("delivery log day: no counter %q", column)
}
if n <= 0 {
return nil
}
_, err := s.db.Exec(fmt.Sprintf(
`INSERT INTO delivery_log_days (day, %[1]s) VALUES (?, ?)
ON CONFLICT (day) DO UPDATE SET %[1]s = %[1]s + excluded.%[1]s`, column),
at.Local().Format(dayLayout), n,
)
if err != nil {
return fmt.Errorf("delivery log day, %s: %w", column, err)
}
return nil
}
// AddReconcileClosed counts n rows the reconcile sweep closed.
func (s *Store) AddReconcileClosed(at time.Time, n int64) error {
return s.addDeliveryLogDay(at, "reconcile_closed", n)
}
// AddReconcileMissed counts n rows the sweep closed whose delivery line was
// in mail.log all along: the tailer did not read it.
func (s *Store) AddReconcileMissed(at time.Time, n int64) error {
return s.addDeliveryLogDay(at, "reconcile_missed", n)
}
// AddTailerBlind counts a stretch the log tailer could not read mail.log,
// on the day it began. Under a second counts nothing.
func (s *Store) AddTailerBlind(from time.Time, d time.Duration) error {
return s.addDeliveryLogDay(from, "tailer_blind_seconds", int64(d/time.Second))
}
// SetQueuedOverLifetime records how many rows are still queued past Postfix's
// queue lifetime. It is a level, not a sum: each sweep overwrites the day's
// value, so a past day keeps what its last sweep saw.
func (s *Store) SetQueuedOverLifetime(at time.Time, n int64) error {
_, err := s.db.Exec(
`INSERT INTO delivery_log_days (day, queued_over_lifetime) VALUES (?, ?)
ON CONFLICT (day) DO UPDATE SET queued_over_lifetime = excluded.queued_over_lifetime`,
at.Local().Format(dayLayout), n,
)
if err != nil {
return fmt.Errorf("delivery log day, queued_over_lifetime: %w", err)
}
return nil
}
// countOpenQuery is CountOpenOlderThan's, by idx_send_log_open (openRows).
const countOpenQuery = `SELECT COUNT(*) FROM send_log WHERE ` + openRows + ` AND created_at < ?`
// CountOpenOlderThan counts the rows still open - "queued" or "deferred" -
// that were accepted before cutoff. created_at is RFC3339 UTC, so the strings
// compare as times.
func (s *Store) CountOpenOlderThan(cutoff time.Time) (int64, error) {
var n int64
err := s.db.QueryRow(countOpenQuery, cutoff.UTC().Format(time.RFC3339)).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count queued send_log rows: %w", err)
}
return n, nil
}
// DeliveryLogDay is one day of the counters, for tests and for reading a copy.
type DeliveryLogDay struct {
Day string
Closed, Missed, Contradicted, BlindSeconds, Stuck int64
}
// DeliveryLogDays returns the counted days, oldest first.
func (s *Store) DeliveryLogDays() ([]DeliveryLogDay, error) {
rows, err := s.db.Query(
`SELECT day, reconcile_closed, reconcile_missed, reconcile_contradicted, tailer_blind_seconds, queued_over_lifetime
FROM delivery_log_days ORDER BY day`)
if err != nil {
return nil, fmt.Errorf("delivery log days: %w", err)
}
defer rows.Close()
var out []DeliveryLogDay
for rows.Next() {
var d DeliveryLogDay
if err := rows.Scan(&d.Day, &d.Closed, &d.Missed, &d.Contradicted, &d.BlindSeconds, &d.Stuck); err != nil {
return nil, fmt.Errorf("scan delivery log day: %w", err)
}
out = append(out, d)
}
return out, rows.Err()
}
// PruneDeliveryLogDays deletes the days older than a year.
func (s *Store) PruneDeliveryLogDays(now time.Time) error {
_, err := s.db.Exec(`DELETE FROM delivery_log_days WHERE day < ?`,
now.Local().AddDate(0, 0, -deliveryLogKeepDays).Format(dayLayout))
if err != nil {
return fmt.Errorf("prune delivery log days: %w", err)
}
return nil
}