Compare commits
3
Commits
4ff032a463
...
2dded921f5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2dded921f5 | ||
|
|
bdeb01681d | ||
|
|
c205106418 |
@@ -5,6 +5,20 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.13.0] - 2026-10-17
|
||||
|
||||
The panel in the container's time zone. No migration; schema 12.
|
||||
|
||||
### Added
|
||||
|
||||
- **The panel in your time zone** (local-time): set `TZ` in `.env` (an IANA
|
||||
name, e.g. `Europe/Moscow`) and every time the panel shows, every day it
|
||||
counts - today on Health, the statistics' days, the days of the log
|
||||
filters - and the stamps of `mail.log` are that zone's. The screens no
|
||||
longer write "UTC" beside a time; **Server › Settings** names the zone in
|
||||
a new read-only box. Unset, it is UTC as before. Changing it is a
|
||||
restart; days counted before stay the old zone's.
|
||||
|
||||
## [2.12.0] - 2026-10-17
|
||||
|
||||
A test message from Server › Health. No migration; schema 12.
|
||||
|
||||
@@ -112,8 +112,8 @@ outbound mail will not reach the real internet without DNS, PTR, and port 25.
|
||||
> local image and pulls nothing:
|
||||
>
|
||||
> ```sh
|
||||
> docker build -f build/Dockerfile --build-arg VERSION=2.12.0 \
|
||||
> -t ghcr.io/mixeme/selfpost:2.12.0 .
|
||||
> docker build -f build/Dockerfile --build-arg VERSION=2.13.0 \
|
||||
> -t ghcr.io/mixeme/selfpost:2.13.0 .
|
||||
> ```
|
||||
|
||||
```sh
|
||||
@@ -122,7 +122,7 @@ docker run --rm -d --name selfpost-try \
|
||||
-e SELFPOST_HOSTNAME=mail.local.test \
|
||||
-e PANEL_COOKIE_SECURE=false \
|
||||
-v selfpost-try-data:/data \
|
||||
ghcr.io/mixeme/selfpost:2.12.0
|
||||
ghcr.io/mixeme/selfpost:2.13.0
|
||||
```
|
||||
|
||||
**Get the setup URL** (pick one):
|
||||
|
||||
@@ -64,6 +64,10 @@ ENV DEBIAN_FRONTEND=noninteractive
|
||||
# than with the whole of systemd, the first alternative of its "Depends:
|
||||
# systemd | systemd-standalone-sysusers | systemd-sysusers" (dpkg -s in the
|
||||
# image, 2026-10-15). Nothing here runs systemd.
|
||||
# tzdata is named for TZ (local-time): the zone of every time the panel shows
|
||||
# and of the stamps Postfix writes in mail.log. supervisor's python3.13 brings
|
||||
# it today (apt-cache rdepends --installed tzdata in the image, 2026-10-17);
|
||||
# a TZ the image did not have would leave both in UTC without a word.
|
||||
RUN echo "postfix postfix/mailname string localhost" | debconf-set-selections \
|
||||
&& echo "postfix postfix/main_mailer_type string Internet Site" | debconf-set-selections \
|
||||
&& apt-get update \
|
||||
@@ -78,6 +82,7 @@ RUN echo "postfix postfix/mailname string localhost" | debconf-set-selections \
|
||||
openssl \
|
||||
curl \
|
||||
systemd-standalone-sysusers \
|
||||
tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Debian's postfix runs its chroot sync on every "postfix check" and "postfix
|
||||
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
var (
|
||||
goEnvRead = regexp.MustCompile(`(?:envDefault|envInt|envBool|envSeconds|envDuration|os\.Getenv|os\.LookupEnv)\("([A-Z][A-Z0-9_]+)"`)
|
||||
scriptEnvRead = regexp.MustCompile(`\$\{([A-Z][A-Z0-9_]+):[-?=]`)
|
||||
docEnvName = regexp.MustCompile("`([A-Z][A-Z0-9_]{2,})`")
|
||||
docEnvName = regexp.MustCompile("`([A-Z][A-Z0-9_]+)`")
|
||||
)
|
||||
|
||||
func readFile(t *testing.T, path string) string {
|
||||
|
||||
@@ -113,6 +113,7 @@ func newPanel(cfg config, st *store.Store) (*web.Server, error) {
|
||||
TLSCertFile: cfg.tlsCertFile,
|
||||
TLSServedCertFile: cfg.tlsServedCertFile,
|
||||
SMTPBanner: cfg.smtpBanner,
|
||||
TimeZone: cfg.timeZone,
|
||||
TLSReloadInterval: cfg.tlsReloadInterval,
|
||||
SignSocket: cfg.signSocket,
|
||||
SignLocalSocket: cfg.signLocalSocket,
|
||||
|
||||
@@ -100,6 +100,9 @@ type config struct {
|
||||
// smtpBanner is Postfix's smtpd_banner, expanded, read from postconf at
|
||||
// the start: Health shows the greeting it makes.
|
||||
smtpBanner string
|
||||
// timeZone is TZ as the container was started with ("" for none): the
|
||||
// zone of every time and day the panel shows, which Settings names.
|
||||
timeZone string
|
||||
|
||||
dkimDir string
|
||||
dkimSelectorDef string
|
||||
@@ -150,6 +153,7 @@ func loadConfig() config {
|
||||
manifestPath: filepath.Join(dataDir, backup.ManifestName),
|
||||
setupTokenPath: envDefault("SELFPOST_SETUP_TOKEN_FILE", filepath.Join(dataDir, "setup-token")),
|
||||
hostname: os.Getenv("SELFPOST_HOSTNAME"),
|
||||
timeZone: os.Getenv("TZ"),
|
||||
// Secure cookies by default (security.md); PANEL_COOKIE_SECURE=false is a
|
||||
// development-only escape hatch for testing over plain HTTP.
|
||||
cookieSecure: envDefault("PANEL_COOKIE_SECURE", "true") != "false",
|
||||
|
||||
@@ -104,19 +104,21 @@ func senderCheckDowntime(markAt, now time.Time, ask func() postfixState) (down t
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// dayPart is the part of a stretch of downtime that fell on one UTC day.
|
||||
// dayPart is the part of a stretch of downtime that fell on one day of the
|
||||
// container's zone.
|
||||
type dayPart struct {
|
||||
day time.Time
|
||||
down time.Duration
|
||||
}
|
||||
|
||||
// byDay cuts the time from 'from' to 'to' at the UTC midnights between them,
|
||||
// so that a night's outage is not all booked on the morning it ended.
|
||||
// byDay cuts the time from 'from' to 'to' at the midnights of the container's
|
||||
// zone between them, so that a night's outage is not all booked on the morning
|
||||
// it ended.
|
||||
func byDay(from, to time.Time) []dayPart {
|
||||
from, to = from.UTC(), to.UTC()
|
||||
from, to = from.Local(), to.Local()
|
||||
var parts []dayPart
|
||||
for from.Before(to) {
|
||||
next := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, time.UTC).AddDate(0, 0, 1)
|
||||
next := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, time.Local).AddDate(0, 0, 1)
|
||||
if next.After(to) {
|
||||
next = to
|
||||
}
|
||||
|
||||
@@ -62,3 +62,15 @@ func TestByDay(t *testing.T) {
|
||||
t.Errorf("no time: %+v", none)
|
||||
}
|
||||
}
|
||||
|
||||
// In a container with TZ=Europe/Moscow a day is Moscow's: an outage from
|
||||
// 20:50 to 21:10 UTC crosses its midnight (21:00 UTC).
|
||||
func TestByDayInTheContainersZone(t *testing.T) {
|
||||
inZone(t, "Europe/Moscow")
|
||||
from := time.Date(2026, 10, 12, 20, 50, 0, 0, time.UTC)
|
||||
got := byDay(from, from.Add(20*time.Minute))
|
||||
if len(got) != 2 || got[0].down != 10*time.Minute || got[1].down != 10*time.Minute ||
|
||||
got[1].day.Format("2006-01-02 15:04") != "2026-10-13 00:00" {
|
||||
t.Errorf("parts = %+v, want ten minutes on the 12th and ten from Moscow's midnight", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package main
|
||||
|
||||
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 })
|
||||
}
|
||||
@@ -60,6 +60,12 @@ PANEL_SESSION_IDLE_DAYS=7
|
||||
# your own recursor, e.g. SELFPOST_DNS_RESOLVERS=10.0.0.2:53
|
||||
# SELFPOST_DNS_RESOLVERS=
|
||||
|
||||
# Time zone of the container, an IANA name: every time the panel shows, the
|
||||
# days it counts (today on Health, the days of the statistics, the log
|
||||
# filters) and the stamps Postfix writes in mail.log. Leave unset for UTC;
|
||||
# changing it is a restart. Server > Settings shows the zone in use.
|
||||
# TZ=Europe/Moscow
|
||||
|
||||
# Comma-separated CIDRs (bare IPs allowed) of reverse proxies allowed to
|
||||
# supply X-Forwarded-For for login/setup rate-limiting. Leave unset unless
|
||||
# you know the exact address of your reverse proxy — trusting the wrong
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
services:
|
||||
selfpost:
|
||||
image: ghcr.io/mixeme/selfpost:2.12.0
|
||||
image: ghcr.io/mixeme/selfpost:2.13.0
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
SELFPOST_HOSTNAME: "${SELFPOST_HOSTNAME:?set the mail/panel hostname, e.g. mail.example.com}"
|
||||
@@ -56,6 +56,9 @@ services:
|
||||
# report what the internet sees rather than what this host's own stub
|
||||
# resolver synthesises. Empty uses public defaults.
|
||||
SELFPOST_DNS_RESOLVERS: "${SELFPOST_DNS_RESOLVERS:-}"
|
||||
# The zone of every time the panel shows and of mail.log's stamps, e.g.
|
||||
# Europe/Moscow. Empty is UTC.
|
||||
TZ: "${TZ:-}"
|
||||
# Reverse-proxy CIDRs allowed to supply X-Forwarded-For for login/setup
|
||||
# rate-limiting. See docs/guide.md "Environment variables" — wrong value lets a
|
||||
# client spoof its rate-limit key.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -172,7 +172,7 @@
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">07</span><h2>Table conventions</h2><div class="sp-end">Always inside <code>table-container</code> · an address breaks after its @</div></div>
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Time, UTC</th><th>Name</th><th>sp-clip</th><th>Status</th><th></th></tr></thead>
|
||||
<thead><tr><th>Time</th><th>Name</th><th>sp-clip</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:01:52</td><td><a href="#"><strong>the row's link</strong></a></td><td class="sp-clip">A long subject is clipped with an ellipsis instead of wrapping the row</td><td><span class="tag is-success is-light">sent</span></td><td class="sp-actions"><a href="#">Details</a><form method="post" action="#"><button type="submit" class="has-text-danger">Delete</button></form></td></tr>
|
||||
</tbody>
|
||||
@@ -207,9 +207,9 @@
|
||||
<div class="sp-box-head"><span class="sp-no">08</span><h2>timeline</h2></div>
|
||||
<div class="sp-box-body">
|
||||
<ol class="sp-timeline">
|
||||
<li><time>13:58:07 UTC</time><strong>Done</strong> — default</li>
|
||||
<li class="sp-warn"><time>13:58:09 UTC</time><strong>Deferred</strong> — sp-warn</li>
|
||||
<li class="sp-fail"><time>14:40:00 UTC</time><strong>Bounced</strong> — sp-fail</li>
|
||||
<li><time>13:58:07</time><strong>Done</strong> — default</li>
|
||||
<li class="sp-warn"><time>13:58:09</time><strong>Deferred</strong> — sp-warn</li>
|
||||
<li class="sp-fail"><time>14:40:00</time><strong>Bounced</strong> — sp-fail</li>
|
||||
<li class="sp-pending"><time>not yet</time>Expected next — sp-pending</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Reports</h2></div>
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Received, UTC</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
|
||||
<thead><tr><th>Received</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 04:40</td><td class="sp-muted">Outlook.com</td><td>20 Sep</td><td>1 903</td><td><span class="tag is-warning is-light">7</span></td><td class="sp-actions"><a href="dmarc-report.html">View</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 02:15</td><td class="sp-muted">google.com</td><td>20 Sep</td><td>6 410</td><td>0</td><td class="sp-actions"><a href="dmarc-report.html">View</a></td></tr>
|
||||
|
||||
@@ -81,8 +81,8 @@
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Reporter</dt><dd>Outlook.com</dd></div>
|
||||
<div><dt>Report id</dt><dd class="sp-mono sp-small">5f1c9a0e7b2d4e61a3</dd></div>
|
||||
<div><dt>Window</dt><dd>20 Sep 00:00 – 21 Sep 00:00 UTC</dd></div>
|
||||
<div><dt>Received</dt><dd>2026-09-21 04:40 UTC</dd></div>
|
||||
<div><dt>Window</dt><dd>20 Sep 00:00 – 21 Sep 00:00</dd></div>
|
||||
<div><dt>Received</dt><dd>2026-09-21 04:40</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Ingest</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#dmarc" title="What the reports are and where they go"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Last report</dt><dd>2026-09-21 06:12 UTC</dd></div>
|
||||
<div><dt>Last report</dt><dd>2026-09-21 06:12</dd></div>
|
||||
<div><dt>This week</dt><dd>14 kept · 0 parse failures</dd></div>
|
||||
<div><dt>Hosted addresses</dt><dd class="sp-mono sp-small">dmarc-reports+<domain>@mail.example.org</dd></div>
|
||||
<div><dt>Retention</dt><dd>500 reports a domain or 90 days</dd></div>
|
||||
@@ -88,7 +88,7 @@
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Recent reports</h2></div>
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Received, UTC</th><th>Domain</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
|
||||
<thead><tr><th>Received</th><th>Domain</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 06:12</td><td><a href="dmarc-domain.html">example.org</a></td><td class="sp-muted">google.com</td><td>20 Sep</td><td>412</td><td>0</td><td class="sp-actions"><a href="dmarc-report.html">View</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 04:40</td><td><a href="dmarc-domain.html">shop.example.org</a></td><td class="sp-muted">Outlook.com</td><td>20 Sep</td><td>1 903</td><td><span class="tag is-warning is-light">7</span></td><td class="sp-actions"><a href="dmarc-report.html">View</a></td></tr>
|
||||
|
||||
@@ -142,10 +142,10 @@
|
||||
<div class="sp-box-head"><span class="sp-no">04</span><h2>TLS certificate</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#certificate" title="When it warns and what to do"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Expires</dt><dd class="sp-mono">2026-11-02 09:14 UTC</dd></div>
|
||||
<div><dt>Expires</dt><dd class="sp-mono">2026-11-02 09:14</dd></div>
|
||||
<div><dt>Names <span class="tag is-success is-light">matches</span></dt><dd class="sp-mono">mail.example.org</dd></div>
|
||||
<div><dt>Renewal expires</dt><dd class="sp-mono">2027-01-31 08:02 UTC</dd></div>
|
||||
<div><dt>Renewal mounted</dt><dd class="sp-mono">2026-10-15 03:12 UTC</dd></div>
|
||||
<div><dt>Renewal expires</dt><dd class="sp-mono">2027-01-31 08:02</dd></div>
|
||||
<div><dt>Renewal mounted</dt><dd class="sp-mono">2026-10-15 03:12</dd></div>
|
||||
</dl>
|
||||
<p class="mt-4 sp-muted">Postfix still serves the certificate above. A renewed one is waiting in the mounted file; Postfix takes it up at its next check for a renewal, at most a day after it was mounted.</p>
|
||||
</div>
|
||||
|
||||
@@ -146,7 +146,7 @@
|
||||
<div class="sp-box-head"><span class="sp-no">04</span><h2>Server</h2></div>
|
||||
<div class="sp-help-topic" id="backup"><h3>Backup and moving a domain</h3><p>Full backup downloads one archive of the whole instance: <code>data/</code> (the database, DKIM keys and application credentials), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code>. Both it and a domain's export ask for your account password - the one you sign in with - at every download, so a session left open is not enough to take them; wrong guesses count against the same limit as a password change. The Postfix queue is not in it. The backup is taken while the server is running, and Postfix keeps moving messages through the queue, so a copy could hold a message twice or miss it; mail still waiting for delivery is therefore not carried over, and a restored server starts with an empty queue. To keep the queue, stop the container and archive the project directory instead. The private key in <code>certs/</code> has to be readable by the panel: give it to group 10998 on the host (<code>chgrp 10998</code>, <code>chmod 640</code>). Until then the panel refuses to make a backup and names the file, rather than hand over an archive without the key. It holds secrets, so leave Encrypt with a password ticked: the file is sealed as <code>.spbk</code>, the password is not stored, and without it nothing can be recovered. To restore, unpack it into an empty project directory (decrypt a <code>.spbk</code> first with <code>selfpost-backup -decrypt</code>) and start the same SelfPost version that made it; the panel refuses data from another version. The reverse-proxy configuration and <code>mail.log</code> are not included. One domain moves on its own: Export domain on its settings page writes a <code>.json</code> (or encrypted <code>.spde</code>) with its DKIM key and its applications' passwords, and Import a domain here reads it into another instance, so the published DNS needs no change.</p></div>
|
||||
<div class="sp-help-topic" id="users"><h3>Users and roles</h3><p>The two roles are a reach, not a rank: every user is an administrator and signs in with a username and password. Global reaches the whole server — Overview, Server, the queue and every domain — and is the only role that adds or deletes domains and users. Domain reaches only the domains assigned to it, outbound and inbound separately (the same name on both lists is two assignments); <em>All</em> includes domains added later, and a domain user needs at least one domain or <em>All</em> on either list. The only global user cannot be demoted or deleted, and you cannot delete the account you are signed in with. A new password set here signs that user out of their other sessions.</p></div>
|
||||
<div class="sp-help-topic" id="settings"><h3>Settings</h3><p>Settings holds what is true of the whole instance. Log retention is how many days the Outbound log keeps its rows, from 7 to 365 and 90 by default; a sweep every six hours deletes older ones, and it is the main driver of how fast <code>/data</code> grows. With the inbound relay on there is a second number, for the Inbound log, with the same range. It does not change the rotation of <code>mail.log</code>, which is daily with about two weeks kept. The sending rate limit shown under it is level 1, the per-client-IP ceiling set in <code>.env</code>: changing it needs a restart, and no level-2 limit can exceed it. Notifications are turned on or off here for everyone.</p></div>
|
||||
<div class="sp-help-topic" id="settings"><h3>Settings</h3><p>Settings holds what is true of the whole instance. The time zone is the container's, <code>TZ</code> in <code>.env</code> (UTC when it is not set): every time the panel shows and every day it counts is that zone's, as are the stamps Postfix writes in <code>mail.log</code>, so the two always agree; changing it is a restart of the container. Log retention is how many days the Outbound log keeps its rows, from 7 to 365 and 90 by default; a sweep every six hours deletes older ones, and it is the main driver of how fast <code>/data</code> grows. With the inbound relay on there is a second number, for the Inbound log, with the same range. It does not change the rotation of <code>mail.log</code>, which is daily with about two weeks kept. The sending rate limit shown under it is level 1, the per-client-IP ceiling set in <code>.env</code>: changing it needs a restart, and no level-2 limit can exceed it. Notifications are turned on or off here for everyone.</p></div>
|
||||
<div class="sp-help-topic" id="notifications"><h3>Notifications</h3><p>The panel writes to its users when something needs them, from <code>postmaster@</code> the server's hostname. Four things are reported. A sending domain's SPF, DKIM or DMARC record stops matching: sent once two hourly checks in a row have found it so, and once more when the record matches again. A level-2 rate limit refuses mail: sent at the first refusal, then at most once a day for the same limit. The TLS certificate is 14 days from expiry, and again at 3 days if it is still the same certificate. A message has waited in the queue for more than three days, with one more message when none is left. A global user is told all of it; a domain administrator is told about the DNS records and limits of their own domains.<br><br>Each user turns notifications on or off under Account and needs an e-mail there; Server › Settings turns them off for everyone. They are the panel's own mail: not rows of the Outbound log, and not counted against any limit.<br><br>For that mail to arrive, the server's own name needs two DNS records of its own, as each of your domains does: a DKIM key, which the panel generates and signs notifications with, and an SPF record, <code>v=spf1 a -all</code>. Server › Settings gives both with the values to publish and whether they are in place, and its Re-check DNS checks them again once they are published; Server › Health shows them too. Without them notifications are still sent, and a receiver may file them as spam. Mail of your domains does not depend on either.</p></div>
|
||||
</div>
|
||||
<div class="box">
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Applied by</dt><dd>rspamd, from map files it rereads within seconds of a change</dd></div>
|
||||
<div><dt>Files</dt><dd><span class="sp-mono">/data/antispam/</span> — six, one for each list and kind of entry</dd></div>
|
||||
<div><dt>Last written</dt><dd>2026-09-11 08:40 UTC</dd></div>
|
||||
<div><dt>Last written</dt><dd>2026-09-11 08:40</dd></div>
|
||||
<div><dt>When both lists match</dt><dd>Deny wins, whichever entries they are.</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
<a class="sp-help" href="help.html#inbound-log" title="What the decisions mean"><i class="ti ti-help-circle"></i></a>
|
||||
</div>
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Time, UTC</th><th>From</th><th>To</th><th>Subject</th><th>Decision</th><th>Upstream</th><th></th></tr></thead>
|
||||
<thead><tr><th>Time</th><th>From</th><th>To</th><th>Subject</th><th>Decision</th><th>Upstream</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:00:31</td><td>ci@<wbr>builds.example<br><span class="sp-mono sp-muted sp-small">192.0.2.90</span></td><td>dev@<wbr>lists.example.org</td><td class="sp-clip">[ci] nightly build passed</td><td><button type="button" class="tag is-success is-light" popovertarget="why-7">accept</button><div class="sp-sheet" id="why-7" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-success is-light">accept</span><h2>Why</h2><button type="button" popovertarget="why-7" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Decided by</th><td>rspamd</td></tr><tr><th>Verdict</th><td>no action, score 1.10 / 15.00</td></tr><tr><th>Subject</th><td>[ci] nightly build passed</td></tr></tbody></table></div></div></div></div></td><td><span class="tag is-light">queued</span></td><td class="sp-actions"><a href="in-filter-lists.html">Deny sender</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:00:18</td><td>t.berg@<wbr>uni.example<br><span class="sp-mono sp-muted sp-small">192.0.2.77</span></td><td>dev@<wbr>lists.example.org</td><td class="sp-clip">Re: [dev] patch series v3</td><td><button type="button" class="tag is-success is-light" popovertarget="why-1">accept</button><div class="sp-sheet" id="why-1" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-success is-light">accept</span><h2>Why</h2><button type="button" popovertarget="why-1" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Decided by</th><td>rspamd</td></tr><tr><th>Verdict</th><td>no action, score 0.40 / 15.00</td></tr><tr><th>Subject</th><td>Re: [dev] patch series v3</td></tr></tbody></table></div></div></div></div></td><td><button type="button" class="tag is-success is-light" popovertarget="up-1">sent</button><div class="sp-sheet" id="up-1" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-success is-light">sent</span><h2>Upstream</h2><button type="button" popovertarget="up-1" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Host</th><td class="sp-mono sp-small">mx.upstream.example[198.51.100.5]:25</td></tr><tr><th>Reply</th><td class="sp-mono sp-small">250 2.0.0 Ok: queued as CE05D10261D</td></tr><tr><th>At</th><td class="sp-mono sp-small">09-21 14:00:19</td></tr></tbody></table></div></div></div></div></td><td class="sp-actions"><a href="in-filter-lists.html">Deny sender</a></td></tr>
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Inbound domain</dt><dd><a href="in-domain.html">lists.example.org</a></dd></div>
|
||||
<div><dt>Client</dt><dd class="sp-mono">203.0.113.140</dd></div>
|
||||
<div><dt>Held</dt><dd>2026-09-21 13:59:02 UTC</dd></div>
|
||||
<div><dt>Held</dt><dd>2026-09-21 13:59:02</dd></div>
|
||||
<div><dt>Deleted on</dt><dd>2026-10-05 · in 14 days</dd></div>
|
||||
<div><dt>Size</dt><dd>48 kB · 1 attachment (<span class="sp-mono">invoice.html</span>)</dd></div>
|
||||
<div><dt>Would go to</dt><dd class="sp-mono">mx.internal:25</dd></div>
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
</form>
|
||||
</div>
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Held, UTC</th><th>From</th><th>To</th><th>Subject</th><th>Why</th><th>Size</th><th>Deleted in</th><th></th></tr></thead>
|
||||
<thead><tr><th>Held</th><th>From</th><th>To</th><th>Subject</th><th>Why</th><th>Size</th><th>Deleted in</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:59:02</td><td>invoice@<wbr>partner-billing.example</td><td>owner@<wbr>lists.example.org</td><td class="sp-clip"><a href="in-quarantine-message.html"><strong>Outstanding invoice — action required</strong></a></td><td class="sp-muted sp-small">score 9.8 · PHISHING</td><td class="sp-muted sp-nowrap">48 kB</td><td class="sp-nowrap">14 days</td><td class="sp-actions"><a href="in-quarantine-message.html">Review</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-20 22:14:40</td><td>no-reply@<wbr>shipping-update.example</td><td>jobs@<wbr>acme.io</td><td class="sp-clip"><a href="in-quarantine-message.html"><strong>Your parcel could not be delivered</strong></a></td><td class="sp-muted sp-small">score 8.1 · URL_SHORTENER</td><td class="sp-muted sp-nowrap">12 kB</td><td class="sp-nowrap">13 days</td><td class="sp-actions"><a href="in-quarantine-message.html">Review</a></td></tr>
|
||||
|
||||
@@ -121,7 +121,7 @@
|
||||
<div class="control"><div class="select is-fullwidth"><select id="mode" name="mode"><option>Manual</option><option selected>Auto (from statistics)</option></select></div></div></div>
|
||||
<div class="field"><label class="label" for="mult">Multiplier (1.5–10, default 3)</label>
|
||||
<div class="control"><input class="input" id="mult" name="auto_multiplier" type="number" min="1.5" max="10" step="0.1" value="3"></div>
|
||||
<p class="help">Computed limit: <strong>288</strong> messages / 3600 s. Peak was 96 msg/h. Last recalculated yesterday 03:00 UTC.</p></div>
|
||||
<p class="help">Computed limit: <strong>288</strong> messages / 3600 s. Peak was 96 msg/h. Last recalculated yesterday 03:00.</p></div>
|
||||
<div class="buttons">
|
||||
<button type="submit" class="button is-primary">Save limit</button>
|
||||
<button type="submit" class="button" formaction="#recalc">Recalculate now</button>
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
<a class="sp-help" href="help.html#log" title="What the statuses mean"><i class="ti ti-help-circle"></i></a>
|
||||
</div>
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Time, UTC</th><th>From</th><th>To</th><th>Subject</th><th>Status</th><th></th></tr></thead>
|
||||
<thead><tr><th>Time</th><th>From</th><th>To</th><th>Subject</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:01:52</td><td>shop@<wbr>shop.example.org</td><td>m.keller@<wbr>gmx.de</td><td class="sp-clip">Your order #48213 has shipped</td><td><span class="tag is-success is-light">sent</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:01:50</td><td>shop@<wbr>shop.example.org</td><td>anna.lind@<wbr>outlook.com</td><td class="sp-clip">Your order #48212 has shipped</td><td><span class="tag is-success is-light">sent</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
|
||||
|
||||
@@ -81,8 +81,8 @@
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Domain</dt><dd><a href="out-domain.html">example.org</a></dd></div>
|
||||
<div><dt>Application</dt><dd class="sp-mono"><a href="out-app.html">alerts</a></dd></div>
|
||||
<div><dt>Accepted</dt><dd>2026-09-21 13:58:07 UTC</dd></div>
|
||||
<div><dt>Status reported</dt><dd>2026-09-21 13:58:09 UTC</dd></div>
|
||||
<div><dt>Accepted</dt><dd>2026-09-21 13:58:07</dd></div>
|
||||
<div><dt>Status reported</dt><dd>2026-09-21 13:58:09</dd></div>
|
||||
<div><dt>Queue id</dt><dd class="sp-mono">4XcB7k2Jm9z1</dd></div>
|
||||
<div><dt>Journal id</dt><dd class="sp-mono">184220</dd></div>
|
||||
</dl>
|
||||
@@ -94,9 +94,9 @@
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>History</h2><a class="sp-help" href="help.html#log" title="What the statuses mean"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<ol class="sp-timeline">
|
||||
<li><time>13:58:07 UTC</time><strong>Accepted</strong> from <span class="sp-mono">alerts</span> at 198.51.100.7</li>
|
||||
<li class="sp-warn"><time>13:58:09 UTC</time><strong>Deferred</strong> — the receiving server asked to retry later</li>
|
||||
<li class="sp-pending"><time>next attempt ≈ 14:08 UTC</time>Still in the <a href="out-queue.html">queue</a>; kept for 5 days, then bounced</li>
|
||||
<li><time>13:58:07</time><strong>Accepted</strong> from <span class="sp-mono">alerts</span> at 198.51.100.7</li>
|
||||
<li class="sp-warn"><time>13:58:09</time><strong>Deferred</strong> — the receiving server asked to retry later</li>
|
||||
<li class="sp-pending"><time>next attempt ≈ 14:08</time>Still in the <a href="out-queue.html">queue</a>; kept for 5 days, then bounced</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Waiting</h2><div class="sp-end">1 message · 3.1 kB</div><a class="sp-help" href="help.html#queue" title="What waits here and how Postfix retries"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Queue id</th><th>Arrived, UTC</th><th>From</th><th>To</th><th>Last reply</th><th></th></tr></thead>
|
||||
<thead><tr><th>Queue id</th><th>Arrived</th><th>From</th><th>To</th><th>Last reply</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="sp-mono">4XcB7k2Jm9z1</td><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:58:07</td><td>alerts@<wbr>example.org</td><td>noc@<wbr>example.org</td><td class="sp-muted">451 4.7.1 Greylisted, try again in 5 minutes</td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
|
||||
</tbody>
|
||||
|
||||
@@ -403,7 +403,8 @@
|
||||
{
|
||||
"is-6": [
|
||||
"box[Notification mail]:record×2",
|
||||
"box[Sending rate limits]:body"
|
||||
"box[Sending rate limits]:body",
|
||||
"box[Time zone]:body"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
<h1 class="title is-3">Mail is flowing. One thing needs attention.</h1>
|
||||
<p class="sp-lead">The TLS certificate expires in 12 days and has not renewed yet.</p>
|
||||
</div>
|
||||
<div class="sp-muted sp-small">Updated 14:02:31 UTC<br>refreshes every 10 s</div>
|
||||
<div class="sp-muted sp-small">Updated 14:02:31<br>refreshes every 10 s</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Checks</h2><div class="sp-end">Ran 14:02 UTC · 3.1 s</div></div>
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Checks</h2><div class="sp-end">Ran 14:02 · 3.1 s</div></div>
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Check</th><th>Result</th><th>Found</th><th>What to do</th></tr></thead>
|
||||
<tbody>
|
||||
@@ -108,7 +108,7 @@
|
||||
</div>
|
||||
<div class="column is-6">
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Last test</dt><dd>14:03 UTC to <span class="sp-mono">mix@elsewhere.example</span></dd></div>
|
||||
<div><dt>Last test</dt><dd>14:03 to <span class="sp-mono">mix@elsewhere.example</span></dd></div>
|
||||
<div><dt>Result</dt><dd><span class="tag is-success is-light">queued</span> <span class="sp-mono sp-small">4XcC1d8Pq2z1</span></dd></div>
|
||||
<div><dt>Then</dt><dd><a href="out-message.html">Follow it in the outbound log</a></dd></div>
|
||||
</dl>
|
||||
|
||||
@@ -139,6 +139,16 @@
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box" id="time-zone">
|
||||
<div class="sp-box-head"><span class="sp-no">05</span><h2>Time zone</h2><div class="sp-end">Read-only</div><a class="sp-help" href="help.html#settings" title="Times and days of the panel"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Zone</dt><dd class="sp-mono">Europe/Moscow</dd><dd class="sp-muted">UTC+03:00. Set in <code>.env</code> (<code>TZ</code>); restart to change.</dd></div>
|
||||
</dl>
|
||||
<p class="mt-4 sp-muted">Every time the panel shows, and every day it counts - today on Health, the days of the statistics - is this zone's, and so are the stamps of <code>mail.log</code>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">07</span><h2>Table conventions</h2><div class="sp-end">Always inside <code>table-container</code> · an address breaks after its @</div></div>
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Time, UTC</th><th>Name</th><th>sp-clip</th><th>Status</th><th></th></tr></thead>
|
||||
<thead><tr><th>Time</th><th>Name</th><th>sp-clip</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:01:52</td><td><a href="#"><strong>the row's link</strong></a></td><td class="sp-clip">A long subject is clipped with an ellipsis instead of wrapping the row</td><td><span class="tag is-success is-light">sent</span></td><td class="sp-actions"><a href="#">Details</a><form method="post" action="#"><button type="submit" class="has-text-danger">Delete</button></form></td></tr>
|
||||
</tbody>
|
||||
@@ -141,9 +141,9 @@
|
||||
<div class="sp-box-head"><span class="sp-no">08</span><h2>timeline</h2></div>
|
||||
<div class="sp-box-body">
|
||||
<ol class="sp-timeline">
|
||||
<li><time>13:58:07 UTC</time><strong>Done</strong> — default</li>
|
||||
<li class="sp-warn"><time>13:58:09 UTC</time><strong>Deferred</strong> — sp-warn</li>
|
||||
<li class="sp-fail"><time>14:40:00 UTC</time><strong>Bounced</strong> — sp-fail</li>
|
||||
<li><time>13:58:07</time><strong>Done</strong> — default</li>
|
||||
<li class="sp-warn"><time>13:58:09</time><strong>Deferred</strong> — sp-warn</li>
|
||||
<li class="sp-fail"><time>14:40:00</time><strong>Bounced</strong> — sp-fail</li>
|
||||
<li class="sp-pending"><time>not yet</time>Expected next — sp-pending</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Reports</h2></div>
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Received, UTC</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
|
||||
<thead><tr><th>Received</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 04:40</td><td class="sp-muted">Outlook.com</td><td>20 Sep</td><td>1 903</td><td><span class="tag is-warning is-light">7</span></td><td class="sp-actions"><a href="dmarc-report.html">View</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 02:15</td><td class="sp-muted">google.com</td><td>20 Sep</td><td>6 410</td><td>0</td><td class="sp-actions"><a href="dmarc-report.html">View</a></td></tr>
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Reporter</dt><dd>Outlook.com</dd></div>
|
||||
<div><dt>Report id</dt><dd class="sp-mono sp-small">5f1c9a0e7b2d4e61a3</dd></div>
|
||||
<div><dt>Window</dt><dd>20 Sep 00:00 – 21 Sep 00:00 UTC</dd></div>
|
||||
<div><dt>Received</dt><dd>2026-09-21 04:40 UTC</dd></div>
|
||||
<div><dt>Window</dt><dd>20 Sep 00:00 – 21 Sep 00:00</dd></div>
|
||||
<div><dt>Received</dt><dd>2026-09-21 04:40</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Ingest</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#dmarc" title="What the reports are and where they go"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Last report</dt><dd>2026-09-21 06:12 UTC</dd></div>
|
||||
<div><dt>Last report</dt><dd>2026-09-21 06:12</dd></div>
|
||||
<div><dt>This week</dt><dd>14 kept · 0 parse failures</dd></div>
|
||||
<div><dt>Hosted addresses</dt><dd class="sp-mono sp-small">dmarc-reports+<domain>@mail.example.org</dd></div>
|
||||
<div><dt>Retention</dt><dd>500 reports a domain or 90 days</dd></div>
|
||||
@@ -22,7 +22,7 @@
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>Recent reports</h2></div>
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Received, UTC</th><th>Domain</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
|
||||
<thead><tr><th>Received</th><th>Domain</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 06:12</td><td><a href="dmarc-domain.html">example.org</a></td><td class="sp-muted">google.com</td><td>20 Sep</td><td>412</td><td>0</td><td class="sp-actions"><a href="dmarc-report.html">View</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 04:40</td><td><a href="dmarc-domain.html">shop.example.org</a></td><td class="sp-muted">Outlook.com</td><td>20 Sep</td><td>1 903</td><td><span class="tag is-warning is-light">7</span></td><td class="sp-actions"><a href="dmarc-report.html">View</a></td></tr>
|
||||
|
||||
@@ -76,10 +76,10 @@
|
||||
<div class="sp-box-head"><span class="sp-no">04</span><h2>TLS certificate</h2><div class="sp-end"><span class="tag is-success is-light">ok</span></div><a class="sp-help" href="help.html#certificate" title="When it warns and what to do"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Expires</dt><dd class="sp-mono">2026-11-02 09:14 UTC</dd></div>
|
||||
<div><dt>Expires</dt><dd class="sp-mono">2026-11-02 09:14</dd></div>
|
||||
<div><dt>Names <span class="tag is-success is-light">matches</span></dt><dd class="sp-mono">mail.example.org</dd></div>
|
||||
<div><dt>Renewal expires</dt><dd class="sp-mono">2027-01-31 08:02 UTC</dd></div>
|
||||
<div><dt>Renewal mounted</dt><dd class="sp-mono">2026-10-15 03:12 UTC</dd></div>
|
||||
<div><dt>Renewal expires</dt><dd class="sp-mono">2027-01-31 08:02</dd></div>
|
||||
<div><dt>Renewal mounted</dt><dd class="sp-mono">2026-10-15 03:12</dd></div>
|
||||
</dl>
|
||||
<p class="mt-4 sp-muted">Postfix still serves the certificate above. A renewed one is waiting in the mounted file; Postfix takes it up at its next check for a renewal, at most a day after it was mounted.</p>
|
||||
</div>
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
<div class="sp-box-head"><span class="sp-no">04</span><h2>Server</h2></div>
|
||||
<div class="sp-help-topic" id="backup"><h3>Backup and moving a domain</h3><p>Full backup downloads one archive of the whole instance: <code>data/</code> (the database, DKIM keys and application credentials), <code>docker-compose.yml</code>, <code>.env</code> and <code>certs/</code>. Both it and a domain's export ask for your account password - the one you sign in with - at every download, so a session left open is not enough to take them; wrong guesses count against the same limit as a password change. The Postfix queue is not in it. The backup is taken while the server is running, and Postfix keeps moving messages through the queue, so a copy could hold a message twice or miss it; mail still waiting for delivery is therefore not carried over, and a restored server starts with an empty queue. To keep the queue, stop the container and archive the project directory instead. The private key in <code>certs/</code> has to be readable by the panel: give it to group 10998 on the host (<code>chgrp 10998</code>, <code>chmod 640</code>). Until then the panel refuses to make a backup and names the file, rather than hand over an archive without the key. It holds secrets, so leave Encrypt with a password ticked: the file is sealed as <code>.spbk</code>, the password is not stored, and without it nothing can be recovered. To restore, unpack it into an empty project directory (decrypt a <code>.spbk</code> first with <code>selfpost-backup -decrypt</code>) and start the same SelfPost version that made it; the panel refuses data from another version. The reverse-proxy configuration and <code>mail.log</code> are not included. One domain moves on its own: Export domain on its settings page writes a <code>.json</code> (or encrypted <code>.spde</code>) with its DKIM key and its applications' passwords, and Import a domain here reads it into another instance, so the published DNS needs no change.</p></div>
|
||||
<div class="sp-help-topic" id="users"><h3>Users and roles</h3><p>The two roles are a reach, not a rank: every user is an administrator and signs in with a username and password. Global reaches the whole server — Overview, Server, the queue and every domain — and is the only role that adds or deletes domains and users. Domain reaches only the domains assigned to it, outbound and inbound separately (the same name on both lists is two assignments); <em>All</em> includes domains added later, and a domain user needs at least one domain or <em>All</em> on either list. The only global user cannot be demoted or deleted, and you cannot delete the account you are signed in with. A new password set here signs that user out of their other sessions.</p></div>
|
||||
<div class="sp-help-topic" id="settings"><h3>Settings</h3><p>Settings holds what is true of the whole instance. Log retention is how many days the Outbound log keeps its rows, from 7 to 365 and 90 by default; a sweep every six hours deletes older ones, and it is the main driver of how fast <code>/data</code> grows. With the inbound relay on there is a second number, for the Inbound log, with the same range. It does not change the rotation of <code>mail.log</code>, which is daily with about two weeks kept. The sending rate limit shown under it is level 1, the per-client-IP ceiling set in <code>.env</code>: changing it needs a restart, and no level-2 limit can exceed it. Notifications are turned on or off here for everyone.</p></div>
|
||||
<div class="sp-help-topic" id="settings"><h3>Settings</h3><p>Settings holds what is true of the whole instance. The time zone is the container's, <code>TZ</code> in <code>.env</code> (UTC when it is not set): every time the panel shows and every day it counts is that zone's, as are the stamps Postfix writes in <code>mail.log</code>, so the two always agree; changing it is a restart of the container. Log retention is how many days the Outbound log keeps its rows, from 7 to 365 and 90 by default; a sweep every six hours deletes older ones, and it is the main driver of how fast <code>/data</code> grows. With the inbound relay on there is a second number, for the Inbound log, with the same range. It does not change the rotation of <code>mail.log</code>, which is daily with about two weeks kept. The sending rate limit shown under it is level 1, the per-client-IP ceiling set in <code>.env</code>: changing it needs a restart, and no level-2 limit can exceed it. Notifications are turned on or off here for everyone.</p></div>
|
||||
<div class="sp-help-topic" id="notifications"><h3>Notifications</h3><p>The panel writes to its users when something needs them, from <code>postmaster@</code> the server's hostname. Four things are reported. A sending domain's SPF, DKIM or DMARC record stops matching: sent once two hourly checks in a row have found it so, and once more when the record matches again. A level-2 rate limit refuses mail: sent at the first refusal, then at most once a day for the same limit. The TLS certificate is 14 days from expiry, and again at 3 days if it is still the same certificate. A message has waited in the queue for more than three days, with one more message when none is left. A global user is told all of it; a domain administrator is told about the DNS records and limits of their own domains.<br><br>Each user turns notifications on or off under Account and needs an e-mail there; Server › Settings turns them off for everyone. They are the panel's own mail: not rows of the Outbound log, and not counted against any limit.<br><br>For that mail to arrive, the server's own name needs two DNS records of its own, as each of your domains does: a DKIM key, which the panel generates and signs notifications with, and an SPF record, <code>v=spf1 a -all</code>. Server › Settings gives both with the values to publish and whether they are in place, and its Re-check DNS checks them again once they are published; Server › Health shows them too. Without them notifications are still sent, and a receiver may file them as spam. Mail of your domains does not depend on either.</p></div>
|
||||
</div>
|
||||
<div class="box">
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Applied by</dt><dd>rspamd, from map files it rereads within seconds of a change</dd></div>
|
||||
<div><dt>Files</dt><dd><span class="sp-mono">/data/antispam/</span> — six, one for each list and kind of entry</dd></div>
|
||||
<div><dt>Last written</dt><dd>2026-09-11 08:40 UTC</dd></div>
|
||||
<div><dt>Last written</dt><dd>2026-09-11 08:40</dd></div>
|
||||
<div><dt>When both lists match</dt><dd>Deny wins, whichever entries they are.</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<a class="sp-help" href="help.html#inbound-log" title="What the decisions mean"><i class="ti ti-help-circle"></i></a>
|
||||
</div>
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Time, UTC</th><th>From</th><th>To</th><th>Subject</th><th>Decision</th><th>Upstream</th><th></th></tr></thead>
|
||||
<thead><tr><th>Time</th><th>From</th><th>To</th><th>Subject</th><th>Decision</th><th>Upstream</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:00:31</td><td>ci@<wbr>builds.example<br><span class="sp-mono sp-muted sp-small">192.0.2.90</span></td><td>dev@<wbr>lists.example.org</td><td class="sp-clip">[ci] nightly build passed</td><td><button type="button" class="tag is-success is-light" popovertarget="why-7">accept</button><div class="sp-sheet" id="why-7" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-success is-light">accept</span><h2>Why</h2><button type="button" popovertarget="why-7" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Decided by</th><td>rspamd</td></tr><tr><th>Verdict</th><td>no action, score 1.10 / 15.00</td></tr><tr><th>Subject</th><td>[ci] nightly build passed</td></tr></tbody></table></div></div></div></div></td><td><span class="tag is-light">queued</span></td><td class="sp-actions"><a href="in-filter-lists.html">Deny sender</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:00:18</td><td>t.berg@<wbr>uni.example<br><span class="sp-mono sp-muted sp-small">192.0.2.77</span></td><td>dev@<wbr>lists.example.org</td><td class="sp-clip">Re: [dev] patch series v3</td><td><button type="button" class="tag is-success is-light" popovertarget="why-1">accept</button><div class="sp-sheet" id="why-1" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-success is-light">accept</span><h2>Why</h2><button type="button" popovertarget="why-1" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Decided by</th><td>rspamd</td></tr><tr><th>Verdict</th><td>no action, score 0.40 / 15.00</td></tr><tr><th>Subject</th><td>Re: [dev] patch series v3</td></tr></tbody></table></div></div></div></div></td><td><button type="button" class="tag is-success is-light" popovertarget="up-1">sent</button><div class="sp-sheet" id="up-1" popover><div class="sp-sheet-in"><div class="sp-sheet-head"><span class="tag is-success is-light">sent</span><h2>Upstream</h2><button type="button" popovertarget="up-1" popovertargetaction="hide" aria-label="Close"><i class="ti ti-x"></i></button></div><div class="sp-sheet-body"><div class="table-container"><table class="table is-fullwidth"><tbody><tr><th>Host</th><td class="sp-mono sp-small">mx.upstream.example[198.51.100.5]:25</td></tr><tr><th>Reply</th><td class="sp-mono sp-small">250 2.0.0 Ok: queued as CE05D10261D</td></tr><tr><th>At</th><td class="sp-mono sp-small">09-21 14:00:19</td></tr></tbody></table></div></div></div></div></td><td class="sp-actions"><a href="in-filter-lists.html">Deny sender</a></td></tr>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Inbound domain</dt><dd><a href="in-domain.html">lists.example.org</a></dd></div>
|
||||
<div><dt>Client</dt><dd class="sp-mono">203.0.113.140</dd></div>
|
||||
<div><dt>Held</dt><dd>2026-09-21 13:59:02 UTC</dd></div>
|
||||
<div><dt>Held</dt><dd>2026-09-21 13:59:02</dd></div>
|
||||
<div><dt>Deleted on</dt><dd>2026-10-05 · in 14 days</dd></div>
|
||||
<div><dt>Size</dt><dd>48 kB · 1 attachment (<span class="sp-mono">invoice.html</span>)</dd></div>
|
||||
<div><dt>Would go to</dt><dd class="sp-mono">mx.internal:25</dd></div>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
</form>
|
||||
</div>
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Held, UTC</th><th>From</th><th>To</th><th>Subject</th><th>Why</th><th>Size</th><th>Deleted in</th><th></th></tr></thead>
|
||||
<thead><tr><th>Held</th><th>From</th><th>To</th><th>Subject</th><th>Why</th><th>Size</th><th>Deleted in</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:59:02</td><td>invoice@<wbr>partner-billing.example</td><td>owner@<wbr>lists.example.org</td><td class="sp-clip"><a href="in-quarantine-message.html"><strong>Outstanding invoice — action required</strong></a></td><td class="sp-muted sp-small">score 9.8 · PHISHING</td><td class="sp-muted sp-nowrap">48 kB</td><td class="sp-nowrap">14 days</td><td class="sp-actions"><a href="in-quarantine-message.html">Review</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-20 22:14:40</td><td>no-reply@<wbr>shipping-update.example</td><td>jobs@<wbr>acme.io</td><td class="sp-clip"><a href="in-quarantine-message.html"><strong>Your parcel could not be delivered</strong></a></td><td class="sp-muted sp-small">score 8.1 · URL_SHORTENER</td><td class="sp-muted sp-nowrap">12 kB</td><td class="sp-nowrap">13 days</td><td class="sp-actions"><a href="in-quarantine-message.html">Review</a></td></tr>
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
<div class="control"><div class="select is-fullwidth"><select id="mode" name="mode"><option>Manual</option><option selected>Auto (from statistics)</option></select></div></div></div>
|
||||
<div class="field"><label class="label" for="mult">Multiplier (1.5–10, default 3)</label>
|
||||
<div class="control"><input class="input" id="mult" name="auto_multiplier" type="number" min="1.5" max="10" step="0.1" value="3"></div>
|
||||
<p class="help">Computed limit: <strong>288</strong> messages / 3600 s. Peak was 96 msg/h. Last recalculated yesterday 03:00 UTC.</p></div>
|
||||
<p class="help">Computed limit: <strong>288</strong> messages / 3600 s. Peak was 96 msg/h. Last recalculated yesterday 03:00.</p></div>
|
||||
<div class="buttons">
|
||||
<button type="submit" class="button is-primary">Save limit</button>
|
||||
<button type="submit" class="button" formaction="#recalc">Recalculate now</button>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<a class="sp-help" href="help.html#log" title="What the statuses mean"><i class="ti ti-help-circle"></i></a>
|
||||
</div>
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Time, UTC</th><th>From</th><th>To</th><th>Subject</th><th>Status</th><th></th></tr></thead>
|
||||
<thead><tr><th>Time</th><th>From</th><th>To</th><th>Subject</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:01:52</td><td>shop@<wbr>shop.example.org</td><td>m.keller@<wbr>gmx.de</td><td class="sp-clip">Your order #48213 has shipped</td><td><span class="tag is-success is-light">sent</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:01:50</td><td>shop@<wbr>shop.example.org</td><td>anna.lind@<wbr>outlook.com</td><td class="sp-clip">Your order #48212 has shipped</td><td><span class="tag is-success is-light">sent</span></td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Domain</dt><dd><a href="out-domain.html">example.org</a></dd></div>
|
||||
<div><dt>Application</dt><dd class="sp-mono"><a href="out-app.html">alerts</a></dd></div>
|
||||
<div><dt>Accepted</dt><dd>2026-09-21 13:58:07 UTC</dd></div>
|
||||
<div><dt>Status reported</dt><dd>2026-09-21 13:58:09 UTC</dd></div>
|
||||
<div><dt>Accepted</dt><dd>2026-09-21 13:58:07</dd></div>
|
||||
<div><dt>Status reported</dt><dd>2026-09-21 13:58:09</dd></div>
|
||||
<div><dt>Queue id</dt><dd class="sp-mono">4XcB7k2Jm9z1</dd></div>
|
||||
<div><dt>Journal id</dt><dd class="sp-mono">184220</dd></div>
|
||||
</dl>
|
||||
@@ -28,9 +28,9 @@
|
||||
<div class="sp-box-head"><span class="sp-no">02</span><h2>History</h2><a class="sp-help" href="help.html#log" title="What the statuses mean"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<ol class="sp-timeline">
|
||||
<li><time>13:58:07 UTC</time><strong>Accepted</strong> from <span class="sp-mono">alerts</span> at 198.51.100.7</li>
|
||||
<li class="sp-warn"><time>13:58:09 UTC</time><strong>Deferred</strong> — the receiving server asked to retry later</li>
|
||||
<li class="sp-pending"><time>next attempt ≈ 14:08 UTC</time>Still in the <a href="out-queue.html">queue</a>; kept for 5 days, then bounced</li>
|
||||
<li><time>13:58:07</time><strong>Accepted</strong> from <span class="sp-mono">alerts</span> at 198.51.100.7</li>
|
||||
<li class="sp-warn"><time>13:58:09</time><strong>Deferred</strong> — the receiving server asked to retry later</li>
|
||||
<li class="sp-pending"><time>next attempt ≈ 14:08</time>Still in the <a href="out-queue.html">queue</a>; kept for 5 days, then bounced</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Waiting</h2><div class="sp-end">1 message · 3.1 kB</div><a class="sp-help" href="help.html#queue" title="What waits here and how Postfix retries"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Queue id</th><th>Arrived, UTC</th><th>From</th><th>To</th><th>Last reply</th><th></th></tr></thead>
|
||||
<thead><tr><th>Queue id</th><th>Arrived</th><th>From</th><th>To</th><th>Last reply</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="sp-mono">4XcB7k2Jm9z1</td><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 13:58:07</td><td>alerts@<wbr>example.org</td><td>noc@<wbr>example.org</td><td class="sp-muted">451 4.7.1 Greylisted, try again in 5 minutes</td><td class="sp-actions"><a href="out-message.html">Details</a></td></tr>
|
||||
</tbody>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<h1 class="title is-3">Mail is flowing. One thing needs attention.</h1>
|
||||
<p class="sp-lead">The TLS certificate expires in 12 days and has not renewed yet.</p>
|
||||
</div>
|
||||
<div class="sp-muted sp-small">Updated 14:02:31 UTC<br>refreshes every 10 s</div>
|
||||
<div class="sp-muted sp-small">Updated 14:02:31<br>refreshes every 10 s</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Checks</h2><div class="sp-end">Ran 14:02 UTC · 3.1 s</div></div>
|
||||
<div class="sp-box-head"><span class="sp-no">01</span><h2>Checks</h2><div class="sp-end">Ran 14:02 · 3.1 s</div></div>
|
||||
<div class="table-container"><table class="table is-fullwidth">
|
||||
<thead><tr><th>Check</th><th>Result</th><th>Found</th><th>What to do</th></tr></thead>
|
||||
<tbody>
|
||||
@@ -42,7 +42,7 @@
|
||||
</div>
|
||||
<div class="column is-6">
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Last test</dt><dd>14:03 UTC to <span class="sp-mono">mix@elsewhere.example</span></dd></div>
|
||||
<div><dt>Last test</dt><dd>14:03 to <span class="sp-mono">mix@elsewhere.example</span></dd></div>
|
||||
<div><dt>Result</dt><dd><span class="tag is-success is-light">queued</span> <span class="sp-mono sp-small">4XcC1d8Pq2z1</span></dd></div>
|
||||
<div><dt>Then</dt><dd><a href="out-message.html">Follow it in the outbound log</a></dd></div>
|
||||
</dl>
|
||||
|
||||
@@ -73,5 +73,15 @@
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box" id="time-zone">
|
||||
<div class="sp-box-head"><span class="sp-no">05</span><h2>Time zone</h2><div class="sp-end">Read-only</div><a class="sp-help" href="help.html#settings" title="Times and days of the panel"><i class="ti ti-help-circle"></i></a></div>
|
||||
<div class="sp-box-body">
|
||||
<dl class="sp-facts">
|
||||
<div><dt>Zone</dt><dd class="sp-mono">Europe/Moscow</dd><dd class="sp-muted">UTC+03:00. Set in <code>.env</code> (<code>TZ</code>); restart to change.</dd></div>
|
||||
</dl>
|
||||
<p class="mt-4 sp-muted">Every time the panel shows, and every day it counts - today on Health, the days of the statistics - is this zone's, and so are the stamps of <code>mail.log</code>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+2
-1
@@ -107,7 +107,7 @@ than the default «meaningful step» rule in [§ Commits and release build](#com
|
||||
2. Git tag `vX.Y.Z` and publish the GitHub Release only on explicit request (see
|
||||
[§ Release image](#release-image)).
|
||||
|
||||
**Planned version cuts** (from pin `2.12.0`; adjust if semver changes mid-track):
|
||||
**Planned version cuts** (from pin `2.13.0`; adjust if semver changes mid-track):
|
||||
|
||||
| Stage | ID | Cut |
|
||||
|---|---|---|
|
||||
@@ -149,6 +149,7 @@ than the default «meaningful step» rule in [§ Commits and release build](#com
|
||||
| 36 | origin-strict | `2.10.4` (PATCH) |
|
||||
| 37 | health-setup-checks | `2.11.0` |
|
||||
| 38 | test-email | `2.12.0` |
|
||||
| 39 | local-time | `2.13.0` |
|
||||
|
||||
Docs-only prep (checklists in plans, no product code) uses the same per-step
|
||||
commit rule but **no** version cut until the next product stage ships.
|
||||
|
||||
+19
-6
@@ -204,14 +204,14 @@ cat ./data/setup-token
|
||||
#### Fixed image tag
|
||||
|
||||
`deploy/docker-compose.yml` pins an explicit version (`ghcr.io/mixeme/selfpost:X.Y.Z`),
|
||||
deliberately never `:latest`. The current pin is `2.12.0`. **No 2.x image is
|
||||
deliberately never `:latest`. The current pin is `2.13.0`. **No 2.x image is
|
||||
published on ghcr.io yet** (the newest there is `1.3.0`); until one is, build
|
||||
it from the checkout under the pinned name, and Compose uses the local image
|
||||
instead of pulling:
|
||||
|
||||
```sh
|
||||
docker build -f build/Dockerfile --build-arg VERSION=2.12.0 \
|
||||
-t ghcr.io/mixeme/selfpost:2.12.0 .
|
||||
docker build -f build/Dockerfile --build-arg VERSION=2.13.0 \
|
||||
-t ghcr.io/mixeme/selfpost:2.13.0 .
|
||||
```
|
||||
|
||||
`VERSION` must equal the tag: it is the version the backup check compares
|
||||
@@ -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
|
||||
@@ -970,7 +983,7 @@ A version mismatch at step 5 refuses to start and leaves `/data` untouched —
|
||||
the panel exits with a message naming the tag to use, e.g.:
|
||||
|
||||
```
|
||||
backup: this backup was created by SelfPost 2.11.0 but this image is 2.12.0 — restore into the matching image (selfpost:2.11.0)
|
||||
backup: this backup was created by SelfPost 2.12.0 but this image is 2.13.0 — restore into the matching image (selfpost:2.12.0)
|
||||
```
|
||||
|
||||
Fix the tag in `docker-compose.yml` (build that tag as under [Fixed image
|
||||
@@ -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
@@ -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 | shipped `2.13.0` | — | — |
|
||||
| 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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
|
||||
@@ -151,8 +151,8 @@ func CheckServedCertificate(served, mounted string, interval time.Duration, now
|
||||
switch {
|
||||
case r.Late:
|
||||
c.Status = Worst(c.Status, StatusWarn)
|
||||
c.Detail = fmt.Sprintf("A renewed certificate has been in the mounted file since %s UTC, longer than Postfix's check for a renewal allows, and Postfix still serves the one above. cert-reload's lines in the container log say why.",
|
||||
r.Mounted.UTC().Format("2006-01-02 15:04"))
|
||||
c.Detail = fmt.Sprintf("A renewed certificate has been in the mounted file since %s, longer than Postfix's check for a renewal allows, and Postfix still serves the one above. cert-reload's lines in the container log say why.",
|
||||
r.Mounted.Local().Format("2006-01-02 15:04"))
|
||||
case c.Status == StatusError:
|
||||
c.Detail += " A renewed one is waiting in the mounted file; Postfix takes it up at its next check for a renewal, at most " + intervalText(interval) + " after it was mounted."
|
||||
default:
|
||||
|
||||
@@ -33,7 +33,7 @@ func compose(hostname, subject string, paragraphs ...string) message {
|
||||
}
|
||||
}
|
||||
|
||||
func stamp(t time.Time) string { return t.UTC().Format("2006-01-02 15:04 UTC") }
|
||||
func stamp(t time.Time) string { return t.Local().Format("2006-01-02 15:04 MST") }
|
||||
|
||||
// plain makes text that came from outside fit for a message body: a queued
|
||||
// message's sender is whatever its sender wrote, and a DNS finding may quote a
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -31,6 +31,7 @@ type Config struct {
|
||||
TLSServedCertFile string
|
||||
TLSReloadInterval time.Duration
|
||||
SMTPBanner string
|
||||
TimeZone string
|
||||
SignSocket string
|
||||
SignLocalSocket string
|
||||
JournalSocket string
|
||||
|
||||
@@ -316,7 +316,7 @@ func formatAutoUpdated(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.UTC().Format("2006-01-02 15:04 UTC")
|
||||
return t.Local().Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
// detailFlash maps a fixed redirect flag to a fixed message, so status text
|
||||
|
||||
@@ -133,10 +133,12 @@ func TestDMARCReportShowsPolicyAndRecords(t *testing.T) {
|
||||
has(t, "report", body,
|
||||
`<h1 class="title is-4">Outlook.com · `, `<a href="/outbound/dmarc">DMARC reports</a> /`, `<a href="/outbound/dmarc/domains/1">example.org</a> /`,
|
||||
"<span class=\"tag is-success is-light\">1\u00a0903 pass</span>", `<span class="tag is-warning is-light">7 fail</span>`,
|
||||
`5f1c9a0e7b2d4e61a3`, `noreply-dmarc@outlook.example`, ` UTC</dd>`,
|
||||
`5f1c9a0e7b2d4e61a3`, `noreply-dmarc@outlook.example`,
|
||||
`none / none / 100`, `<dd class="sp-mono">r / r</dd>`, `dmarc-reports@mail.example.org`,
|
||||
`<h2>Records</h2>`, `Parsed from the aggregate XML`, `203.0.113.25`, `198.51.100.44`,
|
||||
`<span class="tag is-danger is-light">fail</span>`, `<span class="tag is-success is-light">pass</span>`)
|
||||
// The times are the container's zone's, and say no zone (local-time).
|
||||
lacks(t, "report", body, ` UTC</dd>`)
|
||||
}
|
||||
|
||||
// DMARC is a feature of the deployment: with ingest off none of its pages
|
||||
|
||||
@@ -199,8 +199,8 @@ func (h *Handlers) HandleDMARCReport(w http.ResponseWriter, r *http.Request) {
|
||||
ID: rep.ID, Reporter: rep.Reporter, ReportID: rep.ReportID, Domain: rep.Domain, DomainID: d.ID,
|
||||
Contact: rep.ContactEmail, Pass: rep.PassCount, Fail: rep.FailCount,
|
||||
Window: formatDMARCWindow(rep.PeriodBegin, rep.PeriodEnd),
|
||||
Period: rep.PeriodBegin.UTC().Format(stamp) + " – " + rep.PeriodEnd.UTC().Format(stamp) + " UTC",
|
||||
Received: rep.ReceivedAt.UTC().Format("2006-01-02 15:04") + " UTC",
|
||||
Period: rep.PeriodBegin.Local().Format(stamp) + " – " + rep.PeriodEnd.Local().Format(stamp),
|
||||
Received: rep.ReceivedAt.Local().Format("2006-01-02 15:04"),
|
||||
PolicyP: rep.PolicyP, PolicySP: rep.PolicySP, PolicyPct: rep.PolicyPct,
|
||||
PolicyADKIM: rep.PolicyADKIM, PolicyASPF: rep.PolicyASPF, Recipient: rep.Recipient,
|
||||
Hub: true,
|
||||
|
||||
@@ -74,7 +74,7 @@ func TestHealthPageRendersEveryCheck(t *testing.T) {
|
||||
out := renderHealth(t, h, healthReading(), "")
|
||||
for _, want := range []string{
|
||||
"panel", "fatal", "exited too quickly",
|
||||
"mail.example.com", "203.0.113.10 → no PTR record", "2026-11-08 09:14 UTC",
|
||||
"mail.example.com", "203.0.113.10 → no PTR record", "2026-11-08 09:14",
|
||||
`action="/server/health/reload"`, `action="/server/health/recheck"`,
|
||||
`hx-get="/server/health/fragment"`,
|
||||
`<span class="tag is-danger is-light">error</span>`, // postfix, and the reverse DNS box
|
||||
@@ -164,9 +164,9 @@ func TestHealthPageNamesARenewalWaiting(t *testing.T) {
|
||||
c.Cert = cert
|
||||
out := renderHealth(t, h, c, "")
|
||||
for _, want := range []string{
|
||||
"<dt>Expires</dt>", "2026-11-08 09:14 UTC",
|
||||
"<dt>Renewal expires</dt>", "2027-01-31 08:02 UTC",
|
||||
"<dt>Renewal mounted</dt>", "2026-10-15 03:12 UTC",
|
||||
"<dt>Expires</dt>", "2026-11-08 09:14",
|
||||
"<dt>Renewal expires</dt>", "2027-01-31 08:02",
|
||||
"<dt>Renewal mounted</dt>", "2026-10-15 03:12",
|
||||
"Postfix still serves the certificate above.",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
|
||||
@@ -529,7 +529,7 @@ func TestFilterListsWhereTheyGo(t *testing.T) {
|
||||
}
|
||||
body = s.get(s.h.HandleFilterLists, "/inbound/filter-lists", globalPrincipal).Body.String()
|
||||
has(t, "filter on", body, `<span class="tag is-success is-light">applied</span>`,
|
||||
`rspamd, from map files it rereads within seconds of a change`, `<dt>Last written</dt><dd>2026-09-11 08:40 UTC</dd>`,
|
||||
`rspamd, from map files it rereads within seconds of a change`, `<dt>Last written</dt><dd>2026-09-11 08:40</dd>`,
|
||||
`/data/antispam/`)
|
||||
lacks(t, "filter on", body, `not applied`, `Nothing — the spam filter is off`, `INBOUND_ANTISPAM_MILTER`)
|
||||
}
|
||||
|
||||
@@ -387,7 +387,8 @@ func (h *Handlers) outLogPage(r *http.Request) (*view.OutLog, error) {
|
||||
}
|
||||
form := view.OutLogFilter{Domain: filter.Domain, App: filter.AppLogin, Status: filter.Status}
|
||||
// The days, as on the Inbound log: from the first's midnight to the end of
|
||||
// the last, UTC; a day that does not parse is no narrowing.
|
||||
// the last, in the container's zone; a day that does not parse is no
|
||||
// narrowing.
|
||||
if t, err := time.Parse(logDay, q.Get("from")); err == nil {
|
||||
form.From, filter.Since = t.Format(logDay), t
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ func (h *Handlers) renderServerSettings(w http.ResponseWriter, r *http.Request,
|
||||
page.WithNotifications(on, notify.From(h.cfg.Hostname))
|
||||
mail := h.serverMail(false)
|
||||
page.WithMail(mail.records, view.FormatAge(mail.checked, time.Now()))
|
||||
page.WithTimeZone(h.cfg.TimeZone, time.Local, time.Now())
|
||||
// The inbound field exists while there is an inbound journal to keep.
|
||||
if h.cfg.InboundEnabled {
|
||||
page.WithInbound(strconv.Itoa(form.inbound))
|
||||
|
||||
@@ -194,9 +194,9 @@ func (h *Handlers) HandleTestMessage(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
const subject = "SelfPost test message"
|
||||
now := time.Now().UTC()
|
||||
now := time.Now()
|
||||
body := "A test message from SelfPost at " + h.cfg.Hostname + ", sent from Server › Health on " +
|
||||
now.Format("2006-01-02 at 15:04 UTC") + ".\n\nIt came from this server, signed with the DKIM key of its own name. " +
|
||||
now.Format("2006-01-02 at 15:04 MST") + ".\n\nIt came from this server, signed with the DKIM key of its own name. " +
|
||||
"Its page in the panel's Outbound log shows what this mail server answered.\n"
|
||||
row, err := h.store.InsertTestMessage(h.cfg.Hostname, from, to, subject)
|
||||
if err != nil {
|
||||
@@ -290,7 +290,7 @@ func healthPage(m view.Meta, c healthReadings, flash string) *view.Health {
|
||||
cert := c.Cert
|
||||
page.Certificate.End = view.Verdict(string(cert.Status))
|
||||
if !cert.NotAfter.IsZero() {
|
||||
page.CertFacts = append(page.CertFacts, view.Fact{Label: "Expires", Value: view.Plain(cert.NotAfter.UTC().Format("2006-01-02 15:04 UTC")), Mono: true})
|
||||
page.CertFacts = append(page.CertFacts, view.Fact{Label: "Expires", Value: view.Plain(cert.NotAfter.Local().Format("2006-01-02 15:04")), Mono: true})
|
||||
}
|
||||
if len(cert.Names) > 0 {
|
||||
names := view.Fact{Label: "Names", Value: view.Plain(strings.Join(cert.Names, ", ")), Mono: true}
|
||||
@@ -304,8 +304,8 @@ func healthPage(m view.Meta, c healthReadings, flash string) *view.Health {
|
||||
}
|
||||
if r := cert.Renewal; r != nil {
|
||||
page.CertFacts = append(page.CertFacts,
|
||||
view.Fact{Label: "Renewal expires", Value: view.Plain(r.NotAfter.UTC().Format("2006-01-02 15:04 UTC")), Mono: true},
|
||||
view.Fact{Label: "Renewal mounted", Value: view.Plain(r.Mounted.UTC().Format("2006-01-02 15:04 UTC")), Mono: true})
|
||||
view.Fact{Label: "Renewal expires", Value: view.Plain(r.NotAfter.Local().Format("2006-01-02 15:04")), Mono: true},
|
||||
view.Fact{Label: "Renewal mounted", Value: view.Plain(r.Mounted.Local().Format("2006-01-02 15:04")), Mono: true})
|
||||
}
|
||||
page.CertDetail, page.CertProblem = cert.Detail, cert.Status != health.StatusOK
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package handlers
|
||||
|
||||
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 })
|
||||
}
|
||||
@@ -6,7 +6,12 @@ package view
|
||||
// user's own — who they are to the panel and their password — and every role
|
||||
// has it. Settings is the instance's.
|
||||
|
||||
import "strconv"
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Account is the data of the Account page. Two forms, each its own POST: the
|
||||
// profile (Username, Email) and the password. The fields hold what was stored,
|
||||
@@ -78,6 +83,11 @@ type Settings struct {
|
||||
// are signed and authorised with, drawn like a domain's.
|
||||
Mail Box
|
||||
MailRecords []Record
|
||||
|
||||
// TimeZone names the container's zone, which every time and day the
|
||||
// panel shows is in; it is set in .env, so the box only shows it.
|
||||
TimeZone Box
|
||||
ZoneFacts []Fact
|
||||
}
|
||||
|
||||
// NewSettings builds the page. retention is what the field shows (the stored
|
||||
@@ -106,6 +116,7 @@ func NewSettings(m Meta, retention, level1 string) *Settings {
|
||||
Notifications: Box{No: "02", Title: "Notifications", ID: "notifications", Help: topicLink(HelpNotifications, "What is sent and when")},
|
||||
Mail: Box{No: "03", Title: "Notification mail", ID: "notification-mail", End: Plain("DNS records of the server's own name"),
|
||||
Help: topicLink(HelpNotifications, "What the panel sends and what makes it arrive")},
|
||||
TimeZone: Box{No: "05", Title: "Time zone", ID: "time-zone", End: Plain("Read-only"), Help: topicLink(HelpSettings, "Times and days of the panel")},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +144,29 @@ func (s *Settings) WithMail(records []Record, checked string) *Settings {
|
||||
return s
|
||||
}
|
||||
|
||||
// WithTimeZone names the zone: tz is TZ as the container has it ("" is UTC,
|
||||
// Go's and the C library's default), loc the zone the process took from it
|
||||
// (time.Local), at now for its offset. A TZ Go cannot load leaves time.Local
|
||||
// UTC (time/zoneinfo_unix.go, initLocal), and the box says so rather than name
|
||||
// a zone the times are not in.
|
||||
func (s *Settings) WithTimeZone(tz string, loc *time.Location, now time.Time) *Settings {
|
||||
name := strings.TrimPrefix(tz, ":")
|
||||
if name == "" {
|
||||
name = "UTC"
|
||||
}
|
||||
_, offset := now.In(loc).Zone()
|
||||
sign := "+"
|
||||
if offset < 0 {
|
||||
sign, offset = "-", -offset
|
||||
}
|
||||
note := Rich(fmt.Sprintf("UTC%s%02d:%02d. Set in ", sign, offset/3600, offset%3600/60), Code(".env"), " (", Code("TZ"), "); restart to change.")
|
||||
if name != "UTC" && loc.String() != name {
|
||||
note = Rich("Not a zone this image knows, so the times are UTC. Set in ", Code(".env"), " (", Code("TZ"), "); restart to change.")
|
||||
}
|
||||
s.ZoneFacts = []Fact{{Label: "Zone", Value: Plain(name), Mono: true, Note: note}}
|
||||
return s
|
||||
}
|
||||
|
||||
// WithInbound adds the retention of the Inbound log to the Log retention box.
|
||||
// days is what the field shows, like retention in NewSettings. The field exists
|
||||
// only while the inbound relay is on: with it off there is no journal to keep.
|
||||
|
||||
@@ -169,7 +169,7 @@ func TestPageHeadStates(t *testing.T) {
|
||||
Crumbs: []Crumb{{Href: "/outbound/domains", Label: "Outbound domains"}, {Label: "Applications"}},
|
||||
Title: "New password for", Code: "prod-server",
|
||||
Lead: Rich("Lead with ", Code("code")),
|
||||
Note: []string{"Updated 14:02:31 UTC", "refreshes every 10 s"},
|
||||
Note: []string{"Updated 14:02:31", "refreshes every 10 s"},
|
||||
Actions: []Action{{Label: "Re-check DNS", Icon: "ti-refresh", Post: "/x/dns-recheck"}, {Label: "Add", Icon: "ti-plus", Href: "/x/new", Primary: true}, {Label: "Delete", Href: "/x/delete", Danger: true}},
|
||||
}))
|
||||
mustContain(t, out,
|
||||
@@ -177,7 +177,7 @@ func TestPageHeadStates(t *testing.T) {
|
||||
`<p class="sp-kicker"><a href="/outbound/domains">Outbound domains</a> / Applications /</p>`,
|
||||
`<h1 class="title is-3">New password for <span class="sp-mono">prod-server</span></h1>`,
|
||||
`<p class="sp-lead">Lead with <code>code</code></p>`,
|
||||
`<div class="sp-muted sp-small">Updated 14:02:31 UTC<br>refreshes every 10 s</div>`,
|
||||
`<div class="sp-muted sp-small">Updated 14:02:31<br>refreshes every 10 s</div>`,
|
||||
`<form method="post" action="/x/dns-recheck"><button type="submit" class="button"><i class="ti ti-refresh"></i>Re-check DNS</button></form>`,
|
||||
`<a class="button is-primary" href="/x/new"><i class="ti ti-plus"></i>Add</a>`,
|
||||
`<a class="button is-danger is-light" href="/x/delete">Delete</a>`)
|
||||
@@ -352,15 +352,15 @@ func TestSideMenu(t *testing.T) {
|
||||
|
||||
func TestTimelineLevels(t *testing.T) {
|
||||
out := squash(partial(t, "timeline", []Step{
|
||||
{Time: "13:58:07 UTC", Strong: "Accepted", Text: Rich(" from ", Mono("alerts"))},
|
||||
{Time: "13:58:09 UTC", Strong: "Deferred", Text: Plain(" — later"), Level: LevelWarn},
|
||||
{Time: "14:40:00 UTC", Strong: "Bounced", Level: LevelFail},
|
||||
{Time: "13:58:07", Strong: "Accepted", Text: Rich(" from ", Mono("alerts"))},
|
||||
{Time: "13:58:09", Strong: "Deferred", Text: Plain(" — later"), Level: LevelWarn},
|
||||
{Time: "14:40:00", Strong: "Bounced", Level: LevelFail},
|
||||
{Time: "next attempt", Text: Plain("Still queued"), Level: LevelPending},
|
||||
}))
|
||||
mustContain(t, out,
|
||||
`<ol class="sp-timeline"><li><time>13:58:07 UTC</time><strong>Accepted</strong> from <span class="sp-mono">alerts</span></li>`,
|
||||
`<li class="sp-warn"><time>13:58:09 UTC</time><strong>Deferred</strong> — later</li>`,
|
||||
`<li class="sp-fail"><time>14:40:00 UTC</time><strong>Bounced</strong></li>`,
|
||||
`<ol class="sp-timeline"><li><time>13:58:07</time><strong>Accepted</strong> from <span class="sp-mono">alerts</span></li>`,
|
||||
`<li class="sp-warn"><time>13:58:09</time><strong>Deferred</strong> — later</li>`,
|
||||
`<li class="sp-fail"><time>14:40:00</time><strong>Bounced</strong></li>`,
|
||||
`<li class="sp-pending"><time>next attempt</time>Still queued</li>`)
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ func DMARCDomainHref(id int64) string { return outboundDMARC + "/domains/" + str
|
||||
func DMARCReportHref(id int64) string { return outboundDMARC + "/reports/" + strconv.FormatInt(id, 10) }
|
||||
|
||||
// FormatReceived is the time a report was received, as the tables show it.
|
||||
func FormatReceived(t time.Time) string { return t.UTC().Format("01-02 15:04") }
|
||||
func FormatReceived(t time.Time) string { return t.Local().Format("01-02 15:04") }
|
||||
|
||||
// ---- The hub
|
||||
|
||||
@@ -97,7 +97,7 @@ func NewDMARCHub(m Meta, in IngestInput) *DMARCHub {
|
||||
if in.Last.IsZero() {
|
||||
last.Note = Rich("Set a domain to SelfPost hosted in its Domain settings, publish the DMARC record it then shows, and publish MX for ", Mono(in.Host), " pointing at this server.")
|
||||
} else {
|
||||
last.Value = Plain(in.Last.UTC().Format("2006-01-02 15:04") + " UTC")
|
||||
last.Value = Plain(in.Last.Local().Format("2006-01-02 15:04"))
|
||||
}
|
||||
return &DMARCHub{
|
||||
Meta: m,
|
||||
@@ -329,8 +329,8 @@ type ReportInput struct {
|
||||
Contact string
|
||||
Pass, Fail int
|
||||
Window string // the window in the title: "20 Sep"
|
||||
Period string // the window with its times: "20 Sep 00:00 – 21 Sep 00:00 UTC"
|
||||
Received string // "2026-09-21 04:40 UTC"
|
||||
Period string // the window with its times: "20 Sep 00:00 – 21 Sep 00:00"
|
||||
Received string // "2026-09-21 04:40"
|
||||
PolicyP string
|
||||
PolicySP string
|
||||
PolicyPct int
|
||||
|
||||
@@ -12,8 +12,8 @@ func TestDMARCHubShowsIngestAndTheLatestReports(t *testing.T) {
|
||||
pageHas(t, "DMARC reports", out,
|
||||
`<h1 class="title is-3">DMARC reports</h1>`, `<code>rua=</code>`,
|
||||
`<h2>Ingest</h2>`, `<span class="tag is-success is-light">ok</span>`,
|
||||
`<dd>2026-09-21 06:12 UTC</dd>`, `<dd>14 kept · 0 parse failures</dd>`, `<dt>Hosted addresses</dt>`, `<dd class="sp-mono sp-small">dmarc-reports+<domain>@mail.example.org</dd>`, `<dd>500 reports a domain or 90 days</dd>`,
|
||||
`<th>Received, UTC</th>`, `09-21 06:12`, `<a href="/outbound/dmarc/domains/1">example.org</a>`,
|
||||
`<dd>2026-09-21 06:12</dd>`, `<dd>14 kept · 0 parse failures</dd>`, `<dt>Hosted addresses</dt>`, `<dd class="sp-mono sp-small">dmarc-reports+<domain>@mail.example.org</dd>`, `<dd>500 reports a domain or 90 days</dd>`,
|
||||
`<th>Received</th>`, `09-21 06:12`, `<a href="/outbound/dmarc/domains/1">example.org</a>`,
|
||||
"<td>1 903</td>", `<span class="tag is-warning is-light">7</span>`, `<td>0</td><td class="sp-actions"><a href="/outbound/dmarc/reports/41">View</a>`,
|
||||
)
|
||||
}
|
||||
@@ -98,7 +98,7 @@ func TestDMARCReportShowsTheReportItsPolicyAndRecords(t *testing.T) {
|
||||
`<h1 class="title is-4">Outlook.com · 20 Sep</h1>`,
|
||||
`<a href="/outbound/dmarc">DMARC reports</a> / <a href="/outbound/dmarc/domains/2">shop.example.org</a> /`,
|
||||
"<span>shop.example.org</span><span class=\"tag is-success is-light\">1 903 pass</span><span class=\"tag is-warning is-light\">7 fail</span>",
|
||||
`<h2>Report</h2>`, `<dd class="sp-mono sp-small">5f1c9a0e7b2d4e61a3</dd>`, `<dd>20 Sep 00:00 – 21 Sep 00:00 UTC</dd>`, `<dd>2026-09-21 04:40 UTC</dd>`,
|
||||
`<h2>Report</h2>`, `<dd class="sp-mono sp-small">5f1c9a0e7b2d4e61a3</dd>`, `<dd>20 Sep 00:00 – 21 Sep 00:00</dd>`, `<dd>2026-09-21 04:40</dd>`,
|
||||
`<h2>Published policy</h2>`, `<dd class="sp-mono">none / none / 100</dd>`, `<dd class="sp-mono">r / r</dd>`, `<dd class="sp-mono sp-small">dmarc@mail.example.org</dd>`,
|
||||
`<h2>Records</h2>`, `Parsed from the aggregate XML`,
|
||||
`203.0.113.25 <span class="tag is-light">this relay</span>`, "<td>1 903</td>",
|
||||
@@ -111,7 +111,7 @@ func TestDMARCReportShowsTheReportItsPolicyAndRecords(t *testing.T) {
|
||||
// nothing failed, and a report with no records says so.
|
||||
func TestDMARCReportOmitsWhatItDoesNotHold(t *testing.T) {
|
||||
p := NewDMARCReport(admin(), ReportInput{ID: 1, Reporter: "google.com", ReportID: "r1", Domain: "example.org", DomainID: 1, Pass: 5,
|
||||
Period: "20 Sep 00:00 – 20 Sep 23:59 UTC", Received: "2026-09-21 04:40 UTC", PolicyP: "none", PolicySP: "none", PolicyPct: 100, PolicyADKIM: "r", PolicyASPF: "r"})
|
||||
Period: "20 Sep 00:00 – 20 Sep 23:59", Received: "2026-09-21 04:40", PolicyP: "none", PolicySP: "none", PolicyPct: 100, PolicyADKIM: "r", PolicyASPF: "r"})
|
||||
out := renderSignedIn(t, "dmarc-report", p.WithRecords(nil))
|
||||
pageHas(t, "DMARC report", out, `<h1 class="title is-4">google.com</h1>`, `<span class="tag is-success is-light">5 pass</span>`, `This report holds no records.`)
|
||||
pageLacks(t, "DMARC report", out, `fail</span>`, `Contact`, `<dt>rua</dt>`, `<table`, `<a href="/outbound/dmarc">`)
|
||||
|
||||
@@ -113,7 +113,7 @@ func healthFixture(refresh bool) *Health {
|
||||
}
|
||||
h.Certificate.End = Verdict("warn")
|
||||
h.CertFacts = []Fact{
|
||||
{Label: "Expires", Value: Plain("2026-10-21 09:14 UTC"), Mono: true},
|
||||
{Label: "Expires", Value: Plain("2026-10-21 09:14"), Mono: true},
|
||||
{Label: "Names", Value: Plain("mail.example.org"), Mono: true},
|
||||
}
|
||||
h.CertDetail, h.CertProblem = "Expires in 12 day(s). Check that renewal on the host still works.", true
|
||||
@@ -149,16 +149,21 @@ func accountFixture() *Account {
|
||||
const mailFixtureDKIMHost = "sp2026._domainkey.mail.example.org"
|
||||
|
||||
// settingsFixture is the mockup's Settings: thirty days of log, a limit of 600
|
||||
// messages an hour, notifications on, and the records of the server's own name
|
||||
// with the DKIM one published and the SPF one missing.
|
||||
// messages an hour, notifications on, the records of the server's own name
|
||||
// with the DKIM one published and the SPF one missing, and Moscow's time.
|
||||
func settingsFixture() *Settings {
|
||||
msk, err := time.LoadLocation("Europe/Moscow")
|
||||
if err != nil {
|
||||
msk = time.FixedZone("Europe/Moscow", 3*3600)
|
||||
}
|
||||
key := "v=DKIM1; k=rsa; p=" + strings.Repeat("MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A", 10) + "IDAQAB"
|
||||
return NewSettings(admin(), "30", FormatRate(600, 3600)).WithInbound("90").
|
||||
WithNotifications(true, "postmaster@mail.example.org").
|
||||
WithMail([]Record{
|
||||
ServerMailDKIM("sp2026", mailFixtureDKIMHost, key, DNSCheck{Status: "ok"}),
|
||||
ServerMailSPF("mail.example.org", "v=spf1 a -all", DNSCheck{Status: "error", Detail: "No SPF record is published for mail.example.org."}),
|
||||
}, "3 min ago")
|
||||
}, "3 min ago").
|
||||
WithTimeZone("Europe/Moscow", msk, time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC))
|
||||
}
|
||||
|
||||
// outDomainsFixture is the mockup's Outbound domains: three domains, the first
|
||||
@@ -220,7 +225,7 @@ func outDomainSettingsFixture() *OutDomainSettings {
|
||||
p.WithExport(12)
|
||||
return p.WithRateLimit(DomainRateLimit{
|
||||
Active: true, Auto: true, MaxMessages: "288", Window: "3600", Multiplier: "2.5",
|
||||
Computed: "288", Peak: 96, Updated: "2026-10-09 03:00 UTC",
|
||||
Computed: "288", Peak: 96, Updated: "2026-10-09 03:00",
|
||||
L1Messages: 600, L1Window: 3600, MinMultiplier: "1.5", MaxMultiplier: "5", DefaultMultiplier: "2.5",
|
||||
})
|
||||
}
|
||||
@@ -338,7 +343,7 @@ func dmarcDomainFixture() *DMARCDomain {
|
||||
func dmarcReportFixture() *DMARCReport {
|
||||
return NewDMARCReport(admin(), ReportInput{
|
||||
ID: 40, Reporter: "Outlook.com", ReportID: "5f1c9a0e7b2d4e61a3", Domain: "shop.example.org", DomainID: 2,
|
||||
Pass: 1903, Fail: 7, Window: "20 Sep", Period: "20 Sep 00:00 – 21 Sep 00:00 UTC", Received: "2026-09-21 04:40 UTC",
|
||||
Pass: 1903, Fail: 7, Window: "20 Sep", Period: "20 Sep 00:00 – 21 Sep 00:00", Received: "2026-09-21 04:40",
|
||||
PolicyP: "none", PolicySP: "none", PolicyPct: 100, PolicyADKIM: "r", PolicyASPF: "r",
|
||||
Recipient: "dmarc@mail.example.org", Hub: true,
|
||||
}).WithRecords([]DMARCRecordRow{
|
||||
|
||||
@@ -263,7 +263,9 @@ var serverTopics = []HelpTopic{
|
||||
" on either list. The only global user cannot be demoted or deleted, and you cannot delete the account you are signed in with. A new password set here signs that user out of their other sessions."),
|
||||
}},
|
||||
{ID: HelpSettings, Title: "Settings", Body: []Text{
|
||||
Rich("Settings holds what is true of the whole instance. Log retention is how many days the Outbound log keeps its rows, from 7 to 365 and 90 by default; a sweep every six hours deletes older ones, and it is the main driver of how fast ",
|
||||
Rich("Settings holds what is true of the whole instance. The time zone is the container's, ", Code("TZ"), " in ", Code(".env"),
|
||||
" (UTC when it is not set): every time the panel shows and every day it counts is that zone's, as are the stamps Postfix writes in ", Code("mail.log"),
|
||||
", so the two always agree; changing it is a restart of the container. Log retention is how many days the Outbound log keeps its rows, from 7 to 365 and 90 by default; a sweep every six hours deletes older ones, and it is the main driver of how fast ",
|
||||
Code("/data"), " grows. With the inbound relay on there is a second number, for the Inbound log, with the same range. It does not change the rotation of ", Code("mail.log"),
|
||||
", which is daily with about two weeks kept. The sending rate limit shown under it is level 1, the per-client-IP ceiling set in ",
|
||||
Code(".env"), ": changing it needs a restart, and no level-2 limit can exceed it. Notifications are turned on or off here for everyone."),
|
||||
|
||||
@@ -141,7 +141,7 @@ func (p *FilterLists) setWhere(in FilterListsInput) {
|
||||
}
|
||||
written := "never"
|
||||
if !in.LastWritten.IsZero() {
|
||||
written = in.LastWritten.UTC().Format("2006-01-02 15:04") + " UTC"
|
||||
written = in.LastWritten.Local().Format("2006-01-02 15:04")
|
||||
}
|
||||
p.WhereFacts = []Fact{
|
||||
{Label: "Applied by", Value: applied},
|
||||
@@ -159,7 +159,7 @@ func (p *FilterLists) WithEntries(entries []FilterEntryInput) *FilterLists {
|
||||
window := "in the last " + plural(int64(p.retentionDays), "day", "days")
|
||||
sheet := []SheetRow{
|
||||
{Label: "Note", Lines: []string{orDash(e.Note)}},
|
||||
{Label: "Added", Lines: []string{e.Added.UTC().Format("2006-01-02")}, Mono: true},
|
||||
{Label: "Added", Lines: []string{e.Added.Local().Format("2006-01-02")}, Mono: true},
|
||||
}
|
||||
row := FilterRow{
|
||||
Value: e.Value,
|
||||
@@ -169,7 +169,7 @@ func (p *FilterLists) WithEntries(entries []FilterEntryInput) *FilterLists {
|
||||
row.Matched = strconv.Itoa(e.Hits)
|
||||
sheet = append(sheet,
|
||||
SheetRow{Label: "Matched", Lines: []string{row.Matched + " " + window}},
|
||||
SheetRow{Label: "Last match", Lines: []string{e.LastHit.UTC().Format("2006-01-02 15:04")}, Mono: true})
|
||||
SheetRow{Label: "Last match", Lines: []string{e.LastHit.Local().Format("2006-01-02 15:04")}, Mono: true})
|
||||
} else {
|
||||
sheet = append(sheet, SheetRow{Label: "Matched", Lines: []string{"never " + window}})
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ func TestInLogShowsTheRowsAndTheirFilters(t *testing.T) {
|
||||
`<input class="input" type="date" name="from" value="2026-09-14" aria-label="From date">`,
|
||||
`<input class="input" type="date" name="to" value="2026-09-21" aria-label="To date">`,
|
||||
`href="/help#inbound-log" title="What the decisions mean"`,
|
||||
`<th>Time, UTC</th><th>From</th><th>To</th><th>Subject</th><th>Decision</th><th>Upstream</th><th></th></tr>`,
|
||||
`<th>Time</th><th>From</th><th>To</th><th>Subject</th><th>Decision</th><th>Upstream</th><th></th></tr>`,
|
||||
// what the upstream answered: a tag that opens its last reply, a
|
||||
// plain tag while not tried, "—" for refused mail
|
||||
`<button type="button" class="tag is-success is-light" popovertarget="up-1">sent</button><div class="sp-sheet" id="up-1" popover>`,
|
||||
@@ -219,7 +219,7 @@ func TestFilterListsShowsTheFormTheListsAndWhereTheyGo(t *testing.T) {
|
||||
`<h2>Where the lists go</h2><div class="sp-end"><span class="tag is-success is-light">applied</span></div>`,
|
||||
`rspamd, from map files it rereads within seconds of a change`,
|
||||
`<span class="sp-mono">/data/antispam/</span> — six, one for each list and kind of entry`,
|
||||
`2026-09-11 08:40 UTC`, `Deny wins, whichever entries they are.`,
|
||||
`2026-09-11 08:40`, `Deny wins, whichever entries they are.`,
|
||||
)
|
||||
if got := strings.Count(out, `<table class="table is-fullwidth is-hoverable">`); got != 2 {
|
||||
t.Errorf("%d list tables, want the two lists", got)
|
||||
|
||||
@@ -151,9 +151,9 @@ func KitPage() *Kit {
|
||||
|
||||
Timeline: Box{No: "08", Title: "timeline"},
|
||||
Steps: []Step{
|
||||
{Time: "13:58:07 UTC", Strong: "Done", Text: Plain(" — default")},
|
||||
{Time: "13:58:09 UTC", Strong: "Deferred", Text: Plain(" — sp-warn"), Level: LevelWarn},
|
||||
{Time: "14:40:00 UTC", Strong: "Bounced", Text: Plain(" — sp-fail"), Level: LevelFail},
|
||||
{Time: "13:58:07", Strong: "Done", Text: Plain(" — default")},
|
||||
{Time: "13:58:09", Strong: "Deferred", Text: Plain(" — sp-warn"), Level: LevelWarn},
|
||||
{Time: "14:40:00", Strong: "Bounced", Text: Plain(" — sp-fail"), Level: LevelFail},
|
||||
{Time: "not yet", Text: Plain("Expected next — sp-pending"), Level: LevelPending},
|
||||
},
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ func FormatAge(t, now time.Time) string {
|
||||
case d < 48*time.Hour:
|
||||
return fmt.Sprintf("%d h ago", int(d/time.Hour))
|
||||
default:
|
||||
return t.UTC().Format("2006-01-02")
|
||||
return t.Local().Format("2006-01-02")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,11 +62,11 @@ func LogHref(f OutLogFilter, page int) string {
|
||||
}
|
||||
|
||||
// FormatLogTime is the time of a row of the log: month, day and the time of day
|
||||
// in UTC, as the table's "Time, UTC" column shows it.
|
||||
func FormatLogTime(t time.Time) string { return t.UTC().Format("01-02 15:04:05") }
|
||||
// in the container's zone, as the table's "Time" column shows it.
|
||||
func FormatLogTime(t time.Time) string { return t.Local().Format("01-02 15:04:05") }
|
||||
|
||||
// formatStamp is a moment on the page of one message, with its zone.
|
||||
func formatStamp(t time.Time) string { return t.UTC().Format("2006-01-02 15:04:05") + " UTC" }
|
||||
// formatStamp is a moment on the page of one message, in the container's zone.
|
||||
func formatStamp(t time.Time) string { return t.Local().Format("2006-01-02 15:04:05") }
|
||||
|
||||
// ---- The log
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestOutLogShowsTheRowsAndTheirFilters(t *testing.T) {
|
||||
`<form class="sp-end" method="get" action="/outbound/log">`,
|
||||
`<select name="domain" aria-label="Domain"><option value="" selected>All domains</option><option value="example.org">example.org</option>`,
|
||||
`<select name="app" aria-label="Application"><option value="" selected>All applications</option>`,
|
||||
`<th>Time, UTC</th>`, `09-21 14:01:52`,
|
||||
`<th>Time</th>`, `09-21 14:01:52`,
|
||||
`shop@<wbr>shop.example.org`, `m.keller@<wbr>gmx.de`, `title="[FIRING] disk usage above 90 % on db-2"`,
|
||||
`<span class="tag is-success is-light">sent</span>`, `<span class="tag is-warning is-light">deferred</span>`,
|
||||
`<span class="tag is-danger is-light">bounced</span>`, `<span class="tag is-danger is-light">rate-limited</span>`, `<span class="tag is-danger is-light">wrong From</span>`,
|
||||
@@ -113,9 +113,9 @@ func TestOutMessageShowsWhatTheJournalRecorded(t *testing.T) {
|
||||
`<a href="/outbound/log">Outbound log</a> /`,
|
||||
`<p class="sp-route"><span>alerts@example.org</span><i class="ti ti-arrow-right sp-muted"></i><span>noc@example.org</span><span class="tag is-warning is-light">deferred</span></p>`,
|
||||
`<a href="/outbound/domains/1">example.org</a>`, `<dd class="sp-mono"><a href="/outbound/domains/1/applications/2">alerts</a></dd>`,
|
||||
`2026-09-21 13:58:07 UTC`, `2026-09-21 13:58:09 UTC`, `<dd class="sp-mono">4XcB7k2Jm9z1</dd>`, `<dd class="sp-mono">184220</dd>`,
|
||||
`<li><time>2026-09-21 13:58:07 UTC</time><strong>Accepted and queued</strong>`,
|
||||
`<li class="sp-warn"><time>2026-09-21 13:58:09 UTC</time><strong>Deferred, will be retried</strong>`,
|
||||
`2026-09-21 13:58:07`, `2026-09-21 13:58:09`, `<dd class="sp-mono">4XcB7k2Jm9z1</dd>`, `<dd class="sp-mono">184220</dd>`,
|
||||
`<li><time>2026-09-21 13:58:07</time><strong>Accepted and queued</strong>`,
|
||||
`<li class="sp-warn"><time>2026-09-21 13:58:09</time><strong>Deferred, will be retried</strong>`,
|
||||
`<li class="sp-pending"><time>not yet</time>`,
|
||||
`Lines from <code>mail.log</code> with this queue id`, `<pre class="sp-log">`, `<span class="sp-d">13:58:07</span> postfix/smtpd[2114]`,
|
||||
`<span class="sp-w">postfix/smtp[2123]: 4XcB7k2Jm9z1: to=<noc@example.org>`,
|
||||
|
||||
@@ -227,7 +227,7 @@ func TestOutDomainSettingsHasAFormPerBox(t *testing.T) {
|
||||
`name="password"`, `name="password_confirm"`, `minlength="12"`, `at least 12 characters`,
|
||||
`action="/outbound/domains/1/settings/ratelimit"`, `name="mode"`, `data-ratelimit-mode`, `data-manual-fields`, `data-auto-fields`,
|
||||
`name="max_messages"`, `max="600"`, `name="window_seconds"`, `name="auto_multiplier"`, `min="1.5" max="5"`, `Multiplier (1.5–5, default 2.5)`,
|
||||
`Computed limit: <strong>288</strong> messages / 3600 s. Peak was 96 msg/h. Last recalculated 2026-10-09 03:00 UTC.`,
|
||||
`Computed limit: <strong>288</strong> messages / 3600 s. Peak was 96 msg/h. Last recalculated 2026-10-09 03:00.`,
|
||||
`<option value="auto" selected>`, `<span class="tag is-success is-light">active</span>`, `class="sp-help" href="/help#limits"`,
|
||||
`600 messages / 3600 s per client IP, set in <code>.env</code>`,
|
||||
`action="/outbound/domains/1/settings/ratelimit/recalc"`, `name="clear" value="1"`, `data-confirm="Remove the domain rate limit?`,
|
||||
@@ -375,7 +375,7 @@ func TestOutAppAutoLimitHasRecalculate(t *testing.T) {
|
||||
p := outAppFixture().WithForm(OutAppForm{Login: "prod-server", Mode: AddressWildcard, LimitMode: LimitAuto, Window: "3600", Multiplier: "2.5"},
|
||||
OutAppState{Limit: true, Auto: true},
|
||||
AppRateLimit{L1Messages: 600, L1Window: 3600, MinMultiplier: "1.5", MaxMultiplier: "5", DefaultMultiplier: "2.5",
|
||||
Computed: "288", Peak: 96, Updated: "2026-10-09 03:00 UTC"})
|
||||
Computed: "288", Peak: 96, Updated: "2026-10-09 03:00"})
|
||||
out := renderSignedIn(t, "out-app", p)
|
||||
pageHas(t, "Application", out, `formaction="/outbound/domains/1/applications/1/ratelimit/recalc"`, `Recalculate rate limit`,
|
||||
`Computed limit: <strong>288</strong> messages / 3600 s.`)
|
||||
|
||||
@@ -118,7 +118,7 @@ func overviewHead(host string, cards []HealthCard, updated time.Time) Head {
|
||||
Kicker: host,
|
||||
Title: title,
|
||||
ID: "overview-head",
|
||||
Note: []string{"Updated " + updated.UTC().Format("15:04:05") + " UTC"},
|
||||
Note: []string{"Updated " + updated.Local().Format("15:04:05")},
|
||||
}
|
||||
if len(bad) > 0 {
|
||||
lead := Text{{Text: "Needs attention: "}}
|
||||
|
||||
@@ -63,7 +63,7 @@ func TestOverviewHeadFollowsTheCards(t *testing.T) {
|
||||
`<h1 class="title is-3">Running, with warnings.</h1>`,
|
||||
`class="sp-postmark sp-warn"`, `<b>WARN</b>`, `1 OF 6 CHECKS`,
|
||||
`Needs attention: <a href="/server/health">TLS certificate</a> (Expires in 12 days).`,
|
||||
`Updated 14:02:31 UTC`, `<p class="sp-kicker">mail.example.org</p>`,
|
||||
`Updated 14:02:31`, `<p class="sp-kicker">mail.example.org</p>`,
|
||||
`class="sp-warn"`, // the certificate card
|
||||
`href="/server/health"`, `>All details</a>`,
|
||||
)
|
||||
@@ -154,7 +154,7 @@ func TestHealthShowsEveryCheckAndBothActions(t *testing.T) {
|
||||
`<progress class="progress is-small is-success mb-1" value="12" max="100">`, `value="41"`,
|
||||
`↓ 18.0 KiB/s`, `eth0: 2.1 GiB in, 6.4 GiB out`,
|
||||
`pid 41, uptime 18 days`, `<span class="tag is-success is-light">running</span>`,
|
||||
`2026-10-21 09:14 UTC`, `class="mt-4 has-text-danger"`, `Check that renewal on the host still works.`,
|
||||
`2026-10-21 09:14`, `class="mt-4 has-text-danger"`, `Check that renewal on the host still works.`,
|
||||
`dkim-signer <span class="sp-muted">· notifications</span>`, `unix:/run/selfpost/sign-local.sock`, `→ 203.0.113.25<br>← mail.example.org`,
|
||||
`hx-get="/server/health/fragment"`,
|
||||
)
|
||||
@@ -346,6 +346,42 @@ func TestSettingsIsOneFormWithOneSaveButton(t *testing.T) {
|
||||
pageHas(t, "Settings", out, `Save settings`, `<h1 class="title is-3">Settings</h1>`, `href="/account"`)
|
||||
}
|
||||
|
||||
// The time zone is shown, not set: its name and offset, and a TZ the image
|
||||
// does not know is said to give UTC rather than named as the zone in use.
|
||||
func TestSettingsShowsTheTimeZone(t *testing.T) {
|
||||
pageHas(t, "Settings", renderSignedIn(t, "settings", settingsFixture()),
|
||||
`<h2>Time zone</h2>`, `<dd class="sp-mono">Europe/Moscow</dd>`, `UTC+03:00. Set in <code>.env</code> (<code>TZ</code>); restart to change.`,
|
||||
`and so are the stamps of <code>mail.log</code>.`)
|
||||
now := time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC)
|
||||
pageHas(t, "Settings without TZ", renderSignedIn(t, "settings", NewSettings(admin(), "30", "600 / h").WithTimeZone("", time.UTC, now)),
|
||||
`<dd class="sp-mono">UTC</dd>`, `UTC+00:00. Set in`)
|
||||
pageHas(t, "Settings with a wrong TZ", renderSignedIn(t, "settings", NewSettings(admin(), "30", "600 / h").WithTimeZone("Mars/Olympus", time.UTC, now)),
|
||||
`<dd class="sp-mono">Mars/Olympus</dd>`, `Not a zone this image knows, so the times are UTC.`)
|
||||
ny, err := time.LoadLocation("America/New_York")
|
||||
if err != nil {
|
||||
t.Skipf("no zone America/New_York here: %v", err)
|
||||
}
|
||||
pageHas(t, "Settings in New York", renderSignedIn(t, "settings", NewSettings(admin(), "30", "600 / h").WithTimeZone("America/New_York", ny, now)),
|
||||
`UTC-04:00. Set in`)
|
||||
}
|
||||
|
||||
// The panel shows times in the container's zone, with no zone written
|
||||
// beside them (local-time): the row of a log, a message's page, the head
|
||||
// of Overview.
|
||||
func TestTimesAreTheContainersZone(t *testing.T) {
|
||||
inZone(t, "Europe/Moscow")
|
||||
at := time.Date(2026, 9, 21, 22, 30, 5, 0, time.UTC)
|
||||
if got := FormatLogTime(at); got != "09-22 01:30:05" {
|
||||
t.Errorf("FormatLogTime = %q, want the 22nd at 01:30:05 in Moscow", got)
|
||||
}
|
||||
if got := formatStamp(at); got != "2026-09-22 01:30:05" {
|
||||
t.Errorf("formatStamp = %q", got)
|
||||
}
|
||||
if got := FormatReceived(at); got != "09-22 01:30" {
|
||||
t.Errorf("FormatReceived = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Notifications are one switch for the instance, saved with the rest of the
|
||||
// page, and the records of the server's own name are drawn like a domain's.
|
||||
func TestSettingsNotificationsSwitchAndRecords(t *testing.T) {
|
||||
@@ -477,7 +513,7 @@ func TestOverviewPageHasThePollAttributesAndNoOOBMark(t *testing.T) {
|
||||
func TestOverviewFragmentIsTheBoxAndTheHeadOutOfBand(t *testing.T) {
|
||||
frag := renderFragment(t, "overview_poll", overviewFixture())
|
||||
pageHas(t, "fragment", frag,
|
||||
`<div class="sp-head" id="overview-head" hx-swap-oob="true">`, `class="sp-postmark sp-warn"`, `Updated 14:02:31 UTC`,
|
||||
`<div class="sp-head" id="overview-head" hx-swap-oob="true">`, `class="sp-postmark sp-warn"`, `Updated 14:02:31`,
|
||||
`<div class="box" id="health" data-poll aria-live="polite" hx-get="/overview/fragment" hx-trigger="load" hx-swap="outerHTML">`,
|
||||
`Expires in 12 days`)
|
||||
pageLacks(t, "fragment", frag, `<html`, `<body`, `navbar`, `<table`, `Outbound domains`)
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
{{template "box_open" .Recent}}
|
||||
{{if .Rows}}
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Received, UTC</th><th>Domain</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
|
||||
<thead><tr><th>Received</th><th>Domain</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
|
||||
<tbody>{{range .Rows}}
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">{{.Received}}</td><td>{{if .DomainHref}}<a href="{{.DomainHref}}">{{.Domain}}</a>{{else}}{{.Domain}}{{end}}</td><td class="sp-muted">{{.Reporter}}</td><td>{{.Window}}</td><td>{{.PassText}}</td><td>{{if .Fail}}{{template "tag" .FailTag}}{{else}}0{{end}}</td><td class="sp-actions"><a href="{{.Href}}">View</a></td></tr>{{end}}
|
||||
</tbody>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
{{template "box_open" .Reports}}
|
||||
{{if .Rows}}
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Received, UTC</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
|
||||
<thead><tr><th>Received</th><th>Reporter</th><th>Window</th><th>Pass</th><th>Fail</th><th></th></tr></thead>
|
||||
<tbody>{{range .Rows}}
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">{{.Received}}</td><td class="sp-muted">{{.Reporter}}</td><td>{{.Window}}</td><td>{{.PassText}}</td><td>{{if .Fail}}{{template "tag" .FailTag}}{{else}}0{{end}}</td><td class="sp-actions"><a href="{{.Href}}">View</a></td></tr>{{end}}
|
||||
</tbody>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
{{template "box_open" .Messages}}
|
||||
{{if .Rows}}
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Time, UTC</th><th>From</th><th>To</th><th>Subject</th><th>Decision</th><th>Upstream</th><th></th></tr></thead>
|
||||
<thead><tr><th>Time</th><th>From</th><th>To</th><th>Subject</th><th>Decision</th><th>Upstream</th><th></th></tr></thead>
|
||||
<tbody>{{range .Rows}}
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">{{.Time}}</td><td>{{wbr_at .From}}{{if .Client}}<br><span class="sp-mono sp-muted sp-small">{{.Client}}</span>{{end}}</td><td>{{wbr_at .To}}</td>{{if .Subject}}<td class="sp-clip" title="{{.Subject}}">{{.Subject}}</td>{{else}}<td class="sp-clip sp-muted">—</td>{{end}}<td>{{template "sheet_tag" .Why}}</td>{{if .Upstream}}<td>{{template "sheet_tag" .Upstream}}</td>{{else if .Pending}}<td>{{template "tag" .Pending}}</td>{{else}}<td class="sp-muted">—</td>{{end}}<td class="sp-actions">{{if .Action}}<a href="{{.Action.Href}}">{{.Action.Label}}</a>{{end}}</td></tr>{{end}}
|
||||
</tbody>
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
|
||||
{{template "box_open" .Tables}}
|
||||
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Time, UTC</th><th>Name</th><th>sp-clip</th><th>Status</th><th></th></tr></thead>
|
||||
<thead><tr><th>Time</th><th>Name</th><th>sp-clip</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:01:52</td><td><a href="#"><strong>the row's link</strong></a></td><td class="sp-clip">A long subject is clipped with an ellipsis instead of wrapping the row</td><td><span class="{{status_tag "sent"}}">sent</span></td><td class="sp-actions"><a href="#">Details</a>{{template "row_action" .RowAction}}</td></tr>
|
||||
</tbody>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
so the page count follows the rows. */}}
|
||||
{{define "out_log_rows"}}<div class="table-container" id="out-log-rows" data-poll aria-live="polite" hx-get="{{.PollURL}}" hx-trigger="load" hx-swap="outerHTML">
|
||||
{{if .Rows}}<table class="table is-fullwidth is-hoverable">
|
||||
<thead><tr><th>Time, UTC</th><th>From</th><th>To</th><th>Subject</th><th>Status</th><th></th></tr></thead>
|
||||
<thead><tr><th>Time</th><th>From</th><th>To</th><th>Subject</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>{{range .Rows}}
|
||||
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">{{.Time}}</td><td>{{wbr_at .From}}</td><td>{{wbr_at .To}}</td><td class="sp-clip" title="{{.Subject}}">{{.Subject}}</td><td>{{template "tag" .Status}}</td><td class="sp-actions"><a href="{{.Href}}">Details</a></td></tr>{{end}}
|
||||
</tbody>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{{/* Server > Settings (mockup settings.html): what holds for the whole instance.
|
||||
One form, in the left column, with its Save button under its fields; the
|
||||
right column holds what is not a field - the records of the server's own
|
||||
name, the ones notifications are signed and authorised with, and the
|
||||
read-only rate limits. A user's own name, e-mail, password and DMARC
|
||||
name, the ones notifications are signed and authorised with, the
|
||||
read-only rate limits and the read-only time zone. A user's own name, e-mail, password and DMARC
|
||||
address are on Account (account.html). */}}
|
||||
{{define "content"}}
|
||||
{{template "page_head" .Head}}
|
||||
@@ -43,6 +43,13 @@
|
||||
{{template "facts" .RateFacts}}
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
|
||||
{{template "box_open" .TimeZone}}
|
||||
{{template "body_open"}}
|
||||
{{template "facts" .ZoneFacts}}
|
||||
<p class="mt-4 sp-muted">Every time the panel shows, and every day it counts - today on Health, the days of the statistics - is this zone's, and so are the stamps of <code>mail.log</code>.</p>
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package view
|
||||
|
||||
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 })
|
||||
}
|
||||
@@ -69,6 +69,8 @@ type Config struct {
|
||||
// SMTPBanner is Postfix's smtpd_banner as postconf expands it, "" when
|
||||
// it could not be read (health.SMTPBanner).
|
||||
SMTPBanner string
|
||||
// TimeZone is TZ as the container has it, "" for none (Settings names it).
|
||||
TimeZone string
|
||||
// SignSocket and SignLocalSocket are the DKIM signer's two milter
|
||||
// sockets, applications' mail and the panel's own; JournalSocket is the
|
||||
// journal-milter's. The status page dials them: the signer's are required
|
||||
@@ -150,6 +152,7 @@ func New(st *store.Store, domains *domain.Service, apps *app.Service, inboundSvc
|
||||
TLSCertFile: cfg.TLSCertFile,
|
||||
TLSServedCertFile: cfg.TLSServedCertFile,
|
||||
SMTPBanner: cfg.SMTPBanner,
|
||||
TimeZone: cfg.TimeZone,
|
||||
TLSReloadInterval: cfg.TLSReloadInterval,
|
||||
SignSocket: cfg.SignSocket,
|
||||
SignLocalSocket: cfg.SignLocalSocket,
|
||||
|
||||
@@ -87,8 +87,8 @@ func testHealthShowsWhatIsServed(t *testing.T, sc *scenario) {
|
||||
}
|
||||
cert := box(health(), "certificate")
|
||||
for _, want := range []string{
|
||||
"<dt>Expires</dt><dd class=\"sp-mono\">" + served.UTC().Format("2006-01-02 15:04 UTC"),
|
||||
"<dt>Renewal expires</dt><dd class=\"sp-mono\">" + renewalNotAfter.Format("2006-01-02 15:04 UTC"),
|
||||
"<dt>Expires</dt><dd class=\"sp-mono\">" + served.UTC().Format("2006-01-02 15:04"),
|
||||
"<dt>Renewal expires</dt><dd class=\"sp-mono\">" + renewalNotAfter.Format("2006-01-02 15:04"),
|
||||
"<dt>Renewal mounted</dt>",
|
||||
"Postfix still serves the certificate above.",
|
||||
} {
|
||||
|
||||
@@ -401,6 +401,10 @@ func TestE2E(t *testing.T) {
|
||||
run("inbound_bare_newline_is_one_message", func(t *testing.T) {
|
||||
testInboundBareNewline(t, sc)
|
||||
})
|
||||
// Last: it recreates the container in another zone.
|
||||
run("time_zone_is_the_containers", func(t *testing.T) {
|
||||
testTimeZone(t, sc)
|
||||
})
|
||||
}
|
||||
|
||||
// trimZoneSuffix strips the "e2e.test" suffix a fully-qualified DKIM record
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// testTimeZone (local-time): the container started with TZ=Europe/Moscow is
|
||||
// in that zone, all of it. Settings names the zone; a message's page gives
|
||||
// its times in Moscow's; and Postfix, which takes TZ through
|
||||
// import_environment, stamps mail.log in it too, so the lines on that page
|
||||
// read the same clock. Moscow has kept UTC+3 all year since 2014, so a fixed
|
||||
// zone stands for it here whatever zone data the machine running the tests
|
||||
// has.
|
||||
func testTimeZone(t *testing.T, sc *scenario) {
|
||||
h.env = append(h.env, "TZ=Europe/Moscow")
|
||||
if err := h.recreateSelfpost(); err != nil {
|
||||
t.Fatalf("recreate selfpost with TZ: %v", err)
|
||||
}
|
||||
if err := waitForPanelReady(); err != nil {
|
||||
t.Fatalf("panel after setting TZ: %v\n%s", err, h.logs("selfpost"))
|
||||
}
|
||||
if err := waitForSMTPSReady(60 * time.Second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out, err := h.execIn("selfpost", "date", "+%Z"); err != nil || strings.TrimSpace(out) != "MSK" {
|
||||
t.Fatalf("date +%%Z in the container = %q, %v; want MSK", out, err)
|
||||
}
|
||||
_, body, err := sc.panel.get("/server/settings")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{`<h2>Time zone</h2>`, `<dd class="sp-mono">Europe/Moscow</dd>`, `UTC+03:00. Set in`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("Settings is missing %q:\n%.4000s", want, body)
|
||||
}
|
||||
}
|
||||
|
||||
msk := time.FixedZone("MSK", 3*3600)
|
||||
sent := time.Now().In(msk)
|
||||
resp, _, err := sc.panel.postForm("/server/health/test-message", url.Values{"recipient": {recipient}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page := resp.Request.URL.Path
|
||||
if !regexp.MustCompile(`^/outbound/log/\d+$`).MatchString(page) {
|
||||
t.Fatalf("send a test message: landed on %s", page)
|
||||
}
|
||||
// The minute it was sent, or the next one if the clock turned between.
|
||||
minutes := []string{sent.Format("2006-01-02 15:04"), sent.Add(time.Minute).Format("2006-01-02 15:04")}
|
||||
stamps := []string{sent.Format("Jan _2 15:04"), sent.Add(time.Minute).Format("Jan _2 15:04")}
|
||||
if err := waitFor("the test message's page to show it sent", 60*time.Second, time.Second, func() (bool, error) {
|
||||
_, b, err := sc.panel.get(page)
|
||||
body = b
|
||||
return err == nil && strings.Contains(b, `<span class="tag is-success is-light">sent</span>`), err
|
||||
}); err != nil {
|
||||
t.Fatalf("%v\n%.3000s", err, body)
|
||||
}
|
||||
if !strings.Contains(body, "<time>"+minutes[0]) && !strings.Contains(body, "<time>"+minutes[1]) {
|
||||
t.Errorf("the message's page has no time in Moscow's %s:\n%.4000s", minutes[0], body)
|
||||
}
|
||||
if !strings.Contains(body, stamps[0]) && !strings.Contains(body, stamps[1]) {
|
||||
t.Errorf("the message's mail.log lines are not stamped in Moscow's %s:\n%.4000s\n%s", stamps[0], body, mailLogTail(h, "postfix/"))
|
||||
}
|
||||
if strings.Contains(body, " UTC<") {
|
||||
t.Errorf("a time on the message's page still says UTC:\n%.4000s", body)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user