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
@@ -475,10 +475,10 @@ the hole it closed was the same.
cleanup process serves message after message, so each such warning is
counted as a message. The two are counted apart (the owner, 2026-10-14):
connections turned away in `deferred`, messages deferred mid-message in
`messages` of `sender_check_days`, a row per UTC day kept a year, and
`messages` of `sender_check_days`, a row per day of the container's zone (`TZ`) kept a year, and
Server › Health shows today, 30 days and 12 months in a column each. The
counts also see a panel that is running and not answering, which the mark
does not. Downtime is cut at the UTC midnights it crossed. A log read again
does not. Downtime is cut at the midnights of that zone it crossed. A log read again
after an unclean stop does not count again: the count and the place in the
log it reached (the file's fingerprint and the offset past the line) are
saved in one transaction, and while the tailer catches up a warning at or
@@ -712,7 +712,7 @@ lines are gone for good — rotated past fourteen files while the panel was down
or deleted — or that the tailer was not reading.
Whether the sweep earns its place is measured, not argued (audit 2026-10-12,
H3). `delivery_log_days` (migration `0007`) keeps, per UTC day for a year:
H3). `delivery_log_days` (migration `0007`) keeps, per day of the container's zone for a year:
`reconcile_closed`, the rows the sweep closed; `reconcile_missed`, those of
them whose final line - sent, bounced, or the message's expiry - was in
`mail.log` or `mail.log.1` when the sweep closed them - the tailer did not
@@ -1203,7 +1203,7 @@ The tables that carry the panel's model:
`inbound_spam_list` (the filter lists), `dmarc_reports`,
`dmarc_report_records`, `notify_state` and `notify_addressees` (what the
notifier has said and to whom, § Notifications), `sender_check_days` (the
sender check's downtime and the mail it deferred, per UTC day) and
sender check's downtime and the mail it deferred, per day of the container's zone) and
`delivery_log_days` (the queue sweep's measurements, § Log tailer).
Two processes write: the panel, on one connection, and `dmarc-ingest`, which
+15 -2
View File
@@ -249,6 +249,7 @@ expected to set; defaults match the code exactly.
| `SEND_LOG_RETENTION_DAYS` | Initial default for how many days of send-log history are kept before the background sweep deletes rows, and with them the `mail.log` lines kept for each message (1–2 KB a message) — the main driver of `/data` growth over time and of the size of a full backup. After the first panel start, change retention under **Server › Settings** (global role); the env value is only used to seed SQLite when the setting has never been saved. | `90` | `.env` |
| `PANEL_SESSION_IDLE_DAYS` | Sliding idle timeout for the panel login session, in days. There is no absolute cap: a user who keeps coming back stays signed in indefinitely. | `7` | `.env` |
| `SELFPOST_DNS_RESOLVERS` | Comma-separated recursive resolvers the panel's PTR/SPF/DKIM/DMARC checks query directly (so they report what the internet sees, not what this host's stub resolver synthesises). Each is given 2 seconds to answer before the next is asked, and the one that answered last is asked first; a lookup none of them answered shows as not known, never as a wrong record. | `1.1.1.1:53`, `8.8.8.8:53`, `9.9.9.9:53` when unset | `.env` |
| `TZ` | Time zone of the container, an IANA name such as `Europe/Moscow`: every time the panel shows, every day it counts (today on Health, the days of the statistics, the days of the log filters) and the stamps Postfix writes in `mail.log`, so the two agree. Stored times stay UTC, so changing it is only a restart. *Server › Settings* shows the zone in use, and says so when the image does not know the name (the times are then UTC). | UTC | `.env` |
| `TRUSTED_PROXY_CIDR` | Comma-separated CIDRs (bare IPs allowed) of reverse proxies allowed to supply `X-Forwarded-For` for login, setup, and account-change rate-limiting. **Leave unset unless you know the exact address of your reverse proxy.** A wrong value lets a client spoof its rate-limit key by sending a forged `X-Forwarded-For` header — the panel trusts the last hop only when the TCP peer matches one of these CIDRs. With Apache on the host (the default), the panel sees the proxy's connections come from the Docker network's gateway, e.g. `172.18.0.1` (`docker network inspect` shows it; measured 2026-10-15: the panel's `127.0.0.1:8080` is published through Docker's proxy, and a connection to such a port from the host arrives from the gateway). | *(empty — XFF ignored)* | `.env` |
TLS certificate paths (`TLS_CERT_FILE`, `TLS_KEY_FILE`) are fixed in
@@ -488,7 +489,7 @@ readings, in the order of what stops mail first:
connects during an outage is counted too; a message refused in the middle
is one the panel stopped answering for after it had begun. A time marked ≈
is at most what it shows: at that start supervisord could not say since
when Postfix ran. Days are UTC days. The card warns when
when Postfix ran. Days are those of the container's zone (`TZ`). The card warns when
a connection was turned away or a message refused in the last 24 hours. If these numbers do not stay
at nothing, the panel is in the way of your mail more than it should be -
report it.
@@ -575,6 +576,18 @@ that make them arrive, with when they were last checked; **Re-check DNS** at
the top of the page checks them again once they are published (results are
otherwise kept a few minutes).
The last box, **Time zone**, names the zone the panel is in: `TZ` in `.env`
([Environment variables](#environment-variables)), UTC when it is not set,
with its offset from UTC. Every time the panel shows is in that zone, and no
time on the screens says which - this box is where it is said. Every day it
counts is that zone's too: *today* on Health, the days of the statistics,
the days of the log filters. Postfix runs in the same zone, so the stamps of
the `mail.log` lines on Server › System log and on a message's page read the
same clock as the rest of the page. The database keeps its times in UTC, so
changing the zone is a restart and nothing more; the days counted before it
stay days of the old zone, a shift of a few hours in the statistics. A name
the image does not know leaves everything in UTC, and the box says so.
### Notifications
The panel writes to its users when something needs them. Four things are
@@ -1544,7 +1557,7 @@ the only one of the two that does not depend on the panel process.
### Outbound log
**Outbound › Log** (`/outbound/log`) is a searchable send log with server-side
filters by domain, application, status and day (from and to, UTC days, as on
filters by domain, application, status and day (from and to, days of the container's zone, as on
the Inbound log); the status *rejected* is the three kinds of refusal
together, which the table tells apart by their tags. A user with the *domain*
role sees only the rows of their domains. The filters are kept by the pages,
+21 -1
View File
@@ -38,7 +38,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
| panel-notifications | E-mail notifications about important events | shipped `2.3.0` | 10/10 | [plans/panel-notifications.md](plans/panel-notifications.md) |
| delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | shipped `2.2.0` | 8/8 | [plans/delivery-log-storage.md](plans/delivery-log-storage.md) |
| password-reset | Password reset by e-mail | agreed (2026-10-13) | — | — |
| local-time | The panel shows time, and counts its days, in a time zone other than UTC | agreed (2026-10-13; design questions open) | — | — |
| local-time | The panel shows time, and counts its days, in a time zone other than UTC | done 2026-10-17, unreleased | — | — |
| notification-mail-recheck | The Notification mail records on Server › Settings can be checked again from where they are shown | shipped `2.9.0` | — | — |
| settings-save-row | Server › Settings: the Save button no longer stands alone under a long right column | shipped `2.9.0` | — | — |
| dns-record-collapse | A DNS record that is right is shown folded: its name and its tag, the values on request | shipped `2.9.0` | — | — |
@@ -525,6 +525,26 @@ the database's stored instants and the e2e stay as they are.
**Version:** `2.x` MINOR; `agreed`; the plan is written when it is taken up.
**Where the zone comes from, changed (owner, 2026-10-17):** not a setting
in the panel but the container's own zone, `TZ` in `.env`; Settings only
shows it ("Да" to "пояс в `.env`, на Settings только показ?"). His reason
was the trap above: a container cannot change its zone without a restart,
and only the container's zone moves Postfix's stamps with the rest. So the
trap is closed by the third way - the container runs in the zone, Postfix
stamps `mail.log` in it (`TZ` is in Postfix's `import_environment`), and the
tailer reads the stamps in the same zone, as it always did (`lineTime`,
`time.Local`).
**Done (2026-10-17):** design c205106 (no "UTC" on any screen, Settings box
05 *Time zone*, read-only, the help topic). The panel formats every time in
`time.Local`; the day tables key their rows by the local date, the sender
check cuts downtime at local midnights, the log filters' days run from local
midnight; stored instants stay UTC. Notification mail and the test message
write the zone's abbreviation after a time (they are read away from the
panel). The year question settled as the mockups have it. `tzdata` is named
in the image. Tests run in UTC (a `TestMain` in each package that formats
or counts days) and in Moscow where the zone is the point.
---
## phone-desktop-mode
+2 -2
View File
@@ -119,8 +119,8 @@ for operators upgrading from older 2.x images.
| `inbound_spam_log` | Inbound antispam journal: one row per recipient of a message offered on port 25, with the decision (`accept`, `reject`, `tempfail`, `quarantine`) and who made it (`rspamd`, `postfix`, `selfpost`). Metadata only. `inbound_domain_id` (v8) ties a row to its domain by id (v2) |
| `inbound_spam_list` | Allow / deny entries for the external filter (`address`, `domain`, `ip`), instance-wide; unique per list (v2) |
| `notify_state` | What the panel has already written to its users about, one row per subject (`dns:<domain>`, `cert`, `queue`, `limit:<domain>`), so that each thing is said once and a restart says nothing again (v4) |
| `sender_check_days` | What the sender check costs, a row per UTC day that has something to say: seconds the panel did not answer while Postfix ran, connections turned away (`deferred`), messages deferred mid-message (`messages`), and whether the day's downtime is an upper bound (`estimated`). Kept a year (v6, v9) |
| `delivery_log_days` | What the reconcile sweep and the log tailer did, a row per UTC day, kept a year. Read off a copy of the database; no page shows it (v7) |
| `sender_check_days` | What the sender check costs, a row per day of the container's zone (`TZ`) that has something to say: seconds the panel did not answer while Postfix ran, connections turned away (`deferred`), messages deferred mid-message (`messages`), and whether the day's downtime is an upper bound (`estimated`). Kept a year (v6, v9) |
| `delivery_log_days` | What the reconcile sweep and the log tailer did, a row per day of the container's zone, kept a year. Read off a copy of the database; no page shows it (v7) |
| `notify_addressees` | Who a subject's notification is owed to and whether each was handed it; goes with the subject's `notify_state` row (v11) |
| `send_log_lines` | The `mail.log` lines Postfix wrote under a queue id, with the line's own time; what a message's page shows once the log has rotated. No reference to `send_log`: a line belongs to a row by queue id and time, and goes when no row carries its id (v3) |