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>
This commit is contained in:
mix
2026-10-17 01:34:28 +03:00
co-authored by Claude Opus 5.5
parent c205106418
commit bdeb01681d
58 changed files with 484 additions and 117 deletions
+4 -4
View File
@@ -6,7 +6,7 @@ import (
)
// The counters of delivery_log_days (migration 0007): what the reconcile sweep
// and the log tailer did, a row per UTC day, read off a copy of the database
// 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.
@@ -29,7 +29,7 @@ func (s *Store) addDeliveryLogDay(at time.Time, column string, n int64) error {
_, 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.UTC().Format(dayLayout), n,
at.Local().Format(dayLayout), n,
)
if err != nil {
return fmt.Errorf("delivery log day, %s: %w", column, err)
@@ -61,7 +61,7 @@ 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.UTC().Format(dayLayout), n,
at.Local().Format(dayLayout), n,
)
if err != nil {
return fmt.Errorf("delivery log day, queued_over_lifetime: %w", err)
@@ -113,7 +113,7 @@ func (s *Store) DeliveryLogDays() ([]DeliveryLogDay, error) {
// 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.UTC().AddDate(0, 0, -deliveryLogKeepDays).Format(dayLayout))
now.Local().AddDate(0, 0, -deliveryLogKeepDays).Format(dayLayout))
if err != nil {
return fmt.Errorf("prune delivery log days: %w", err)
}
+8 -7
View File
@@ -231,7 +231,8 @@ type InboundSpamRow struct {
//
// Domain, Decision, Since and Until are the operator's own filters, chosen in
// the UI: an empty field matches everything. Since and Until are days, both
// inclusive and read as UTC days whatever the time of day they carry.
// inclusive and read as days of the container's zone whatever the time of
// day they carry.
//
// DomainIDs is the authorization scope, which no query parameter may widen:
// the exhaustive set of inbound domains the caller may read, by id - a row of
@@ -325,17 +326,17 @@ func inboundSpamWhere(f InboundSpamFilter) (string, []any) {
args = append(args, f.Decision)
}
// created_at is RFC3339 UTC, so comparing it as text against the same
// format is comparing moments: from midnight of the first day up to, not
// including, midnight after the last.
// format is comparing moments: from the container zone's midnight of the
// first day up to, not including, its midnight after the last.
if !f.Since.IsZero() {
y, m, d := f.Since.UTC().Date()
y, m, d := f.Since.Date()
clauses = append(clauses, "created_at >= ?")
args = append(args, time.Date(y, m, d, 0, 0, 0, 0, time.UTC).Format(time.RFC3339))
args = append(args, time.Date(y, m, d, 0, 0, 0, 0, time.Local).UTC().Format(time.RFC3339))
}
if !f.Until.IsZero() {
y, m, d := f.Until.UTC().Date()
y, m, d := f.Until.Date()
clauses = append(clauses, "created_at < ?")
args = append(args, time.Date(y, m, d+1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339))
args = append(args, time.Date(y, m, d+1, 0, 0, 0, 0, time.Local).UTC().Format(time.RFC3339))
}
if len(clauses) == 0 {
return "", nil
+14 -4
View File
@@ -215,7 +215,8 @@ func TestQueryInboundSpamFilters(t *testing.T) {
if got := to(InboundSpamFilter{Decision: SpamDecisionReject}); got != "25" {
t.Errorf("decision = %q, want 25", got)
}
// Days are whole UTC days and both ends are in: 21 st is 00:00 up to 23:59.
// Days are whole days of the container's zone (UTC here, TestMain) and
// both ends are in: 21 st is 00:00 up to 23:59.
if got := to(InboundSpamFilter{Since: day(21, 15), Until: day(21, 3)}); got != "32" {
t.Errorf("one day = %q, want 32", got)
}
@@ -225,10 +226,11 @@ func TestQueryInboundSpamFilters(t *testing.T) {
if got := to(InboundSpamFilter{Until: day(20, 0)}); got != "15" {
t.Errorf("until = %q, want 15", got)
}
// A time in another zone is read as the UTC day it falls on.
// A day is the date it carries, whatever zone the time is in: the 22nd at
// 02:00 in Moscow is the 22nd.
msk := time.FixedZone("msk", 3*3600)
if got := to(InboundSpamFilter{Since: time.Date(2026, 9, 22, 2, 0, 0, 0, msk)}); got != "432" {
t.Errorf("since in another zone = %q, want 432 (it is the 21st in UTC)", got)
if got := to(InboundSpamFilter{Since: time.Date(2026, 9, 22, 2, 0, 0, 0, msk)}); got != "4" {
t.Errorf("since in another zone = %q, want 4 (the 22nd)", got)
}
if got := to(InboundSpamFilter{Domain: "b.example", Decision: SpamDecisionAccept, Since: day(21, 0), Until: day(21, 0)}); got != "3" {
t.Errorf("all filters = %q, want 3", got)
@@ -260,6 +262,14 @@ func TestQueryInboundSpamFilters(t *testing.T) {
if got := to(InboundSpamFilter{Domain: "a.example' OR '1'='1"}); got != "" {
t.Errorf("injected domain = %q", got)
}
// In a container with TZ=Europe/Moscow the 21st is that zone's: from 20 Sep
// 21:00 UTC, so it takes the row of the 20th at 23:00 UTC and leaves the
// 22nd's at 00:00 UTC, which is 03:00 there.
inZone(t, "Europe/Moscow")
if got := to(InboundSpamFilter{Since: day(21, 0), Until: day(21, 0)}); got != "321" {
t.Errorf("one day in Moscow = %q, want 321", got)
}
}
func TestListSpamListHits(t *testing.T) {
+7 -7
View File
@@ -26,7 +26,7 @@ const (
// Settings keys of the sender check's statistics.
const (
// settingSenderCheckSince is the first UTC day the statistics cover.
// 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).
@@ -66,7 +66,7 @@ func (s *Store) StartSenderCheckStats(now time.Time) error {
if err != nil || v != "" {
return err
}
return s.SetSetting(settingSenderCheckSince, now.UTC().Format(dayLayout))
return s.SetSetting(settingSenderCheckSince, now.Local().Format(dayLayout))
}
// AddSenderCheckDown adds to the day of at the time the panel did not answer
@@ -81,7 +81,7 @@ func (s *Store) AddSenderCheckDown(at time.Time, d time.Duration, estimated bool
`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.UTC().Format(dayLayout), secs, estimated,
at.Local().Format(dayLayout), secs, estimated,
)
if err != nil {
return fmt.Errorf("sender check downtime: %w", err)
@@ -114,7 +114,7 @@ func (s *Store) AddSenderCheckDeferred(at time.Time, n int64, place string, mess
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.UTC().Format(dayLayout), n,
at.Local().Format(dayLayout), n,
); err != nil {
return fmt.Errorf("sender check deferred: %w", err)
}
@@ -163,7 +163,7 @@ func (s *Store) SenderCheckDeferredAt() (time.Time, error) {
// 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.UTC()
now = now.Local()
var out SenderCheckStats
sum := func(days int) (SenderCheckTotals, error) {
var secs, deferred, messages, estimated int64
@@ -193,7 +193,7 @@ func (s *Store) SenderCheckStats(now time.Time) (SenderCheckStats, error) {
if err != nil {
return out, err
}
out.Since, _ = time.Parse(dayLayout, since)
out.Since, _ = time.ParseInLocation(dayLayout, since, time.Local)
deferredAt, err := s.GetSetting(settingSenderCheckDeferredAt)
if err != nil {
return out, err
@@ -205,7 +205,7 @@ func (s *Store) SenderCheckStats(now time.Time) (SenderCheckStats, error) {
// 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.UTC().AddDate(0, 0, -senderCheckKeepDays).Format(dayLayout))
now.Local().AddDate(0, 0, -senderCheckKeepDays).Format(dayLayout))
if err != nil {
return fmt.Errorf("prune sender check days: %w", err)
}
+7 -6
View File
@@ -224,7 +224,7 @@ func (s *Store) UpdateStatus(queueID, recipient, status string) (int64, error) {
if _, err := tx.Exec(
`INSERT INTO delivery_log_days (day, reconcile_contradicted) VALUES (?, ?)
ON CONFLICT (day) DO UPDATE SET reconcile_contradicted = reconcile_contradicted + excluded.reconcile_contradicted`,
now.Format(dayLayout), contradicted,
now.Local().Format(dayLayout), contradicted,
); err != nil {
return 0, fmt.Errorf("update send_log status: count the contradicted sweep: %w", err)
}
@@ -500,7 +500,8 @@ type SendLogFilter struct {
DomainIDs []int64
AllDomains bool
// Status is a row's status, and Since and Until are days: from the
// first's midnight up to, not including, midnight after the last, UTC.
// first's midnight up to, not including, midnight after the last, in the
// container's zone.
// Zero values do not narrow.
Status string
Since, Until time.Time
@@ -589,14 +590,14 @@ func sendLogWhere(f SendLogFilter) (string, []any) {
// created_at is RFC3339 UTC, so comparing it as text against the same
// format is comparing moments (as InboundSpamFilter's days).
if !f.Since.IsZero() {
y, m, d := f.Since.UTC().Date()
y, m, d := f.Since.Date()
clauses = append(clauses, "created_at >= ?")
args = append(args, time.Date(y, m, d, 0, 0, 0, 0, time.UTC).Format(time.RFC3339))
args = append(args, time.Date(y, m, d, 0, 0, 0, 0, time.Local).UTC().Format(time.RFC3339))
}
if !f.Until.IsZero() {
y, m, d := f.Until.UTC().Date()
y, m, d := f.Until.Date()
clauses = append(clauses, "created_at < ?")
args = append(args, time.Date(y, m, d+1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339))
args = append(args, time.Date(y, m, d+1, 0, 0, 0, 0, time.Local).UTC().Format(time.RFC3339))
}
if len(clauses) == 0 {
return "", nil
+28
View File
@@ -0,0 +1,28 @@
package store
import (
"os"
"testing"
"time"
)
// TestMain runs the tests in UTC, whatever the zone of the machine they run
// on: the panel shows times and counts days in time.Local, the container's
// zone (TZ), and the fixtures are written in UTC. The tests of another zone
// set it themselves (inZone).
func TestMain(m *testing.M) {
time.Local = time.UTC
os.Exit(m.Run())
}
// inZone runs the rest of the test in the zone name, as a container started
// with TZ=name; the test must not be parallel.
func inZone(t *testing.T, name string) {
t.Helper()
loc, err := time.LoadLocation(name)
if err != nil {
t.Skipf("no zone %s here: %v", name, err)
}
time.Local = loc
t.Cleanup(func() { time.Local = time.UTC })
}