From ddafc96babeb31e01befea2b363b15d2ffae3061 Mon Sep 17 00:00:00 2001 From: Mix Date: Sat, 17 Oct 2026 02:32:44 +0300 Subject: [PATCH] docs-audit: the guide and the panel's words against the code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner's "проверь, что документация актуальна с кодом" (2026-10-16); design c5edbd8 and f1cc3f3 settled which side moves. The panel follows its mockups: an inbound domain's MX box ends with "Checked N ago" (dnscheck.InboundMXCheckedAt, from the cache) and its side menu has See also › Log for this domain (the Inbound log, filtered); a deferred message's history ends with the open dot of the wait in the queue, the lifetime from this Postfix; Health's lead says six cards; Overview's head says it refreshes on its own; DMARC's hint for an unaligned third party is the mockup's sentence. Help, word for word with the mockup and true to the code: the application's password kept by the server (sasldb2) for an export, a From:/Sender: refusal one row of the Outbound log, every backup password check counted (AllowPasswordChange before the compare), a password set for another user ends all their sessions, the limit mail daily per sending domain. The Filter lists topic and its menu entry are no longer shown to a domain administrator: their page is 404 to one (requireInboundGlobal), and Help shows only what its viewer can open. guide.md: Health's readings in the order of the boxes, with Notification mail; the menu and role tables have the Inbound log and Filter lists; port 25's chain has the inbound journal; level 2 does not depend on the client address, the client list refuses a message without one; four kinds of refusal (wrong Sender); the log is filtered, not searched, and a message is traced by its row; a bounced row shows only the queue lifetime; TRUSTED_PROXY_CIDR serves sign-in and setup, the account-change limit is per user; notifications' first check and hourly ceiling. The Apache vhost's comment names mod_rewrite. Tests: TestHelpHasTheInboundLogAndFilterListsTopics (the topic per viewer), TestDeliveryPageDeferredUsesRetryPolicy (the wait), TestInDomainShowsTheMockupsBoxes (checked, the log link), the Overview head; the DMARC tests take the new hint. go vet + go test (26 packages) and make e2e on the test server for this tree. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 13 ++++ deploy/apache/selfpost-vhost.conf | 2 +- docs/guide.md | 74 +++++++++++-------- internal/dmarc/parse.go | 2 +- internal/dnscheck/dnscheck.go | 13 ++++ .../web/handlers/handlers_dmarc_pages_test.go | 4 +- internal/web/handlers/handlers_inbound.go | 7 ++ internal/web/handlers/handlers_monitor.go | 6 +- .../web/handlers/handlers_monitor_test.go | 3 + internal/web/view/dmarc_test.go | 2 +- internal/web/view/fixtures_test.go | 7 +- internal/web/view/health.go | 2 +- internal/web/view/help.go | 38 ++++++++-- internal/web/view/help_test.go | 6 ++ internal/web/view/inbound.go | 22 ++++++ internal/web/view/inbound_domain_test.go | 3 +- internal/web/view/overview.go | 2 +- internal/web/view/pages_test.go | 2 +- 18 files changed, 156 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55c17d9..15f9a85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,19 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version It counts characters now, as the backup password always did. Passwords already set are not affected. +### Changed + +- **The panel and its mockups say the same** (docs-audit): an inbound + domain's MX box says when it was checked and its side menu links the + domain's Inbound log; a deferred message's history ends with the wait in + the queue; Overview says it refreshes on its own; DMARC's hint for an + unaligned third party reads as the mockup. Help says what the code does: + an application's password is kept by the server so an export can carry + it, every password check of a backup counts, a password set for another + user signs them out everywhere, the limit mail is daily per sending + domain, and a domain administrator is no longer shown the Filter lists + topic (their page is the global role's). + ### Documentation - The documents checked against the code (docs-audit, 2026-10-17): the diff --git a/deploy/apache/selfpost-vhost.conf b/deploy/apache/selfpost-vhost.conf index 34ded93..89b02d7 100644 --- a/deploy/apache/selfpost-vhost.conf +++ b/deploy/apache/selfpost-vhost.conf @@ -1,6 +1,6 @@ # Example host Apache vhost for the SelfPost panel (default scenario). Install # on the host running Apache — NOT inside the SelfPost container. Requires -# mod_ssl and mod_proxy/mod_proxy_http enabled (`a2enmod ssl proxy proxy_http`). +# mod_ssl, mod_proxy/mod_proxy_http and mod_rewrite enabled (`a2enmod ssl proxy proxy_http rewrite`). # # Certificates: obtain with the Apache certbot plugin against THIS vhost # (`certbot --apache -d mail.example.com`). Certbot edits this file in place to diff --git a/docs/guide.md b/docs/guide.md index 066adec..33f80a9 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -84,8 +84,9 @@ Since `2.7.0` a message sent on 465 or 587 does not tell its recipients where the application runs: the `Received:` header SelfPost adds reads `Received: by mail.example.com (Postfix) id 4AbC12xyz for <...>; `, without the application's address, host name or login. To trace a message -someone complains about, take that queue id to *Outbound › Log*; mail.log -keeps the client address on smtpd's `client=` line for the same id. +someone complains about, find its row in *Outbound › Log* (by domain, +application and day; the message's page shows its queue id); mail.log keeps +the client address on smtpd's `client=` line for the same id. ### Local trial @@ -250,7 +251,7 @@ expected to set; defaults match the code exactly. | `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` | +| `TRUSTED_PROXY_CIDR` | Comma-separated CIDRs (bare IPs allowed) of reverse proxies allowed to supply `X-Forwarded-For` for sign-in and setup rate-limiting (account-change attempts are counted per user, whatever the address). **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 [deploy/docker-compose.yml](../deploy/docker-compose.yml) to match the `./certs` @@ -422,7 +423,7 @@ changes. |---|---|---| | Overview | Overview | `/overview` | | Outbound | Domains · Log · Queue · DMARC reports | `/outbound/domains`, `/outbound/log`, `/outbound/queue`, `/outbound/dmarc` | -| Inbound | Domains | `/inbound/domains` | +| Inbound | Domains · Log · Filter lists (global role) | `/inbound/domains`, `/inbound/log`, `/inbound/filter-lists` | | Server | Health · System log · Backup · Users · Settings | `/server/health`, `/server/log`, `/server/backup`, `/server/users`, `/server/settings` | | User menu (your name) | Account · Help · Sign out | `/account`, `/help`, `POST /logout` | @@ -460,19 +461,9 @@ their helpers), TLS certificate, queue, milter sockets and reverse DNS. A check the panel could not run shows as a warning, never as fine. Below the cards are the outbound domain list and, with the inbound relay on, the inbound one. Each card leads to its table on **Server › Health** (`/server/health`) — the queue card to the Outbound queue — and Health has the -readings, in the order of what stops mail first: +readings, numbered in the order of what stops mail first: -- **Machine** — processor (core and thread counts), memory and swap of the - host (a memory limit set on the container is not reflected), and - per-interface network throughput and totals of the container's own network - interfaces, read from the kernel's counters; CPU and throughput are measured between refreshes, so they appear - one refresh after the page opens. A fully busy processor or a machine out of - memory is a warning, because both delay or kill the mail path; throughput is - only reported. -- **Processes**, **TLS certificate** (validity and expiry, and whether its - names cover `SELFPOST_HOSTNAME` - the tag beside *Names*; a certificate for - another name fails, as a client that checks the name refuses it) and - **Milter sockets**: the DKIM signer's two - `dkim-signer · applications` for what +- **Processes** and **Milter sockets**: the DKIM signer's two - `dkim-signer · applications` for what applications send on 465/587, `dkim-signer · notifications` for the panel's own mail - the sender check, the send log (the journal-milter) and, with one configured, the spam filter. Postfix attaches the signer @@ -493,12 +484,24 @@ readings, in the order of what stops mail first: 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. +- **TLS certificate** — validity and expiry, and whether its names cover + `SELFPOST_HOSTNAME` (the tag beside *Names*); a certificate for another + name fails, as a client that checks the name refuses it. - **Hostname and reverse DNS** — compares `SELFPOST_HOSTNAME` against the PTR record the internet publishes for this server's IP (forward-confirmed reverse DNS), see [Server-level DNS](#server-level-dns-ptrrdns); use *Re-check DNS* after changing DNS. *Greeting* is the `220` line Postfix opens every connection with; it must name the hostname, and another name there is a warning. +- **Notification mail** — whether the DNS records the panel's own mail is + signed and authorised with are in place (see [Notifications](#notifications)). +- **Machine** — processor (core and thread counts), memory and swap of the + host (a memory limit set on the container is not reflected), and + per-interface network throughput and totals of the container's own network + interfaces, read from the kernel's counters; CPU and throughput are measured between refreshes, so they appear + one refresh after the page opens. A fully busy processor or a machine out of + memory is a warning, because both delay or kill the mail path; throughput is + only reported. - **Your address** (in the Configuration card) — the address the panel sees your browser at, and whether it took it from `X-Forwarded-For`. Behind a reverse proxy that the panel does not trust it is the proxy's own: every @@ -606,7 +609,9 @@ assigned to them. A message goes to the e-mail on the user's [Account](#account) page and only while *Send me notifications* is ticked there; a user without an e-mail gets none. **Server › Settings** has one switch that turns notifications off for everyone. With it off nothing is checked, so -what is wrong when it is turned on again is reported then. +what is wrong when it is turned on again is reported then. The first check runs +three minutes after the panel starts, then every hour; the panel sends at +most 60 notifications an hour, a ceiling nothing should come near. Notifications are the panel's own mail. They are sent from `postmaster@`, one message per recipient, are not rows of @@ -662,7 +667,8 @@ What the *domain* role reaches inside what is assigned: | Overview, Server, the Outbound queue | yes | no (404) | | Outbound: domain page, applications, Domain settings, log | every domain | assigned domains | | Outbound › DMARC reports | every report, with the ingest statistics | reports of assigned domains, without the ingest box | -| Inbound: domain page, upstream, recipients | every domain | assigned inbound domains | +| Inbound: domain page, upstream, recipients, log | every domain | assigned inbound domains | +| Inbound › Filter lists | yes | no (404) | | Add or delete a domain, either direction | yes | no | | Export a domain (Domain settings) | yes | assigned domains | | Import a domain (Server › Backup) | yes | no | @@ -1256,7 +1262,8 @@ inbound mail is being refused with a temporary error (`tempfail`) or going throu (`accept`). **Inbound mail is not in the Outbound log.** Port 25 runs its own milter chain: -the optional anti-spam milter, and nothing else. The DKIM signer does not sign relayed +the optional anti-spam milter and the panel's inbound journal (Inbound › Log, +fail-open), not the signer, the sender check or the outbound journal. The DKIM signer does not sign relayed mail (and a signing outage therefore cannot hold it up), and the journal-milter does not file it — the [Outbound log](#outbound-log) stays a record of what *you* sent. @@ -1521,9 +1528,10 @@ gateway, not its own address (see [Rate limiting — level 1](#rate-limiting--level-1-ip-backstop)). It is **not an authentication boundary.** The SASL login itself still succeeds; -the check runs in the journal-milter, on the same fail-open path as level 2 -(below), so if that milter does not answer the restriction is skipped rather -than enforced. Use it to narrow where an application may send from, not to contain a +the check runs in the journal-milter and is fail-open like level 2 (below): +if that milter does not answer, or its lookup hits a store error, the +restriction is skipped rather than enforced. A message whose client address +Postfix did not pass is refused, not let through. Use it to narrow where an application may send from, not to contain a leaked password — for that, regenerate the password. **Level 2 — application** — optional override of the domain limit for one @@ -1544,8 +1552,9 @@ hours and on demand via **Recalculate now**. **Level 2 is best-effort, not a guarantee.** It runs inside the journal-milter and is deliberately fail-open: if the rate-limit lookup hits -a store error, or the connecting client's IP is not available to the -milter, level 2 is skipped and the message is accepted rather than held up. +a store error, level 2 is skipped and the message is accepted rather than +held up. The client address plays no part: a ceiling is on a domain or an +application, wherever its mail comes from. The same applies if the journal-milter itself does not answer — Postfix is configured to accept rather than refuse it when it cannot reach it. (Since `2.4.0` a panel that is down altogether makes Postfix refuse mail with a @@ -1556,9 +1565,9 @@ 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 +**Outbound › Log** (`/outbound/log`) is the send log, with server-side 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 +the Inbound log); the status *rejected* is the four 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, the refresh and the way back from a message, and the log refreshes its rows @@ -1575,19 +1584,20 @@ the row as a failure rather than show a delivery it cannot evidence, and counts that it did. A refused row says why in its tag: `rate-limited` (a [level-2 rate limit](#rate-limiting--level-2-domain-and-application)), `wrong client` (the -application's client IP list) or `wrong From` (see [Who an application may -send as](#who-an-application-may-send-as)); a row refused before `2.4.0` +application's client IP list), `wrong From` or `wrong Sender` (see [Who an +application may send as](#who-an-application-may-send-as)); a row refused before `2.4.0` says `rejected`. A refused message's page shows what Postfix wrote about the -refusal: for `wrong From` the lines of the queue id the message had, for the -other two the one `NOQUEUE` line; a row refused before `2.5.1` says the line +refusal: for `wrong From` and `wrong Sender` the lines of the queue id the +message had, for the other two the one `NOQUEUE` line; a row refused before `2.5.1` says the line was not kept, and one the panel wrote from `mail.log` after a restart - it had stopped before writing the refusal itself - says so; *Details* opens that row's own page (`/outbound/log/{id}`). That page carries the sending domain, the application it was submitted under, the Postfix queue id and the journal id, beside the message's history — when it was accepted and what Postfix later reported for the recipient. A `deferred` -or `bounced` row includes this Postfix's retry intervals (first delay, -backoff cap, queue lifetime), the same numbers the Outbound queue shows; users +row includes this Postfix's retry intervals (first delay, backoff cap, queue +lifetime), a `bounced` one the queue lifetime, the same numbers the Outbound +queue shows; users with the *domain* role see them here because they cannot open the queue. Under both sit the `mail.log` lines for its queue id: the connection to the receiving server, the server's reply, and the status that reply was filed diff --git a/internal/dmarc/parse.go b/internal/dmarc/parse.go index 2270f05..036aba8 100644 --- a/internal/dmarc/parse.go +++ b/internal/dmarc/parse.go @@ -255,7 +255,7 @@ func TightenPolicyHint(pass, fail int, sources []SourceHint) string { if fail > 0 { for _, s := range sources { if s.FailCount > 0 && !s.ThisRelay { - return "A third-party source is not aligned. Do not tighten p= until that sender is fixed or removed." + return "Failures come from a source that is not this relay — review before tightening p=." } } } diff --git a/internal/dnscheck/dnscheck.go b/internal/dnscheck/dnscheck.go index 14a9ee9..0aecda3 100644 --- a/internal/dnscheck/dnscheck.go +++ b/internal/dnscheck/dnscheck.go @@ -264,6 +264,19 @@ func (c *Checker) InboundMX(name, hostname string, force bool) Result { return result } +// InboundMXCheckedAt is when the MX of name was last looked up for +// hostname, zero when it has not been: the cache is filled when it is, and +// keeps the result domainTTL. +func (c *Checker) InboundMXCheckedAt(name, hostname string) time.Time { + c.mu.Lock() + defer c.mu.Unlock() + entry, ok := c.inbound[name+"\x00"+hostname] + if !ok { + return time.Time{} + } + return entry.expires.Add(-c.domainTTL) +} + func (c *Checker) checkInboundMX(ctx context.Context, name, hostname string) Result { mxs, err := c.resolver.LookupMX(ctx, name) if err != nil { diff --git a/internal/web/handlers/handlers_dmarc_pages_test.go b/internal/web/handlers/handlers_dmarc_pages_test.go index f0340c8..331a3cf 100644 --- a/internal/web/handlers/handlers_dmarc_pages_test.go +++ b/internal/web/handlers/handlers_dmarc_pages_test.go @@ -109,12 +109,12 @@ func TestDMARCDomainShowsSourcesAndWhatToDoAboutThem(t *testing.T) { has(t, "domain", body, `

example.org

`, `DMARC reports /`, `class="sp-postmark sp-warn"`, `99.6%`, `PASS · 7 DAYS`, - "1\u00a0903 pass · 7 fail in the last 7 days. A third-party source is not aligned. Do not tighten p=", + "1\u00a0903 pass · 7 fail in the last 7 days. Failures come from a source that is not this relay — review before tightening p=", `href="/outbound/domains/1"`, `

Sources · 7 days

`, `198.51.100.44`, `203.0.113.25`, `

Reports

`, `href="/outbound/dmarc/reports/`+idStr(s.reportID)+`">View`) // 198.51.100.44 passed nothing, so its disposition is amber. has(t, "domain", body, `07none`) - lacks(t, "domain", body, `this relay`) + lacks(t, "domain", body, `>this relay`) } func TestDMARCDomainWithoutReportsSaysSo(t *testing.T) { diff --git a/internal/web/handlers/handlers_inbound.go b/internal/web/handlers/handlers_inbound.go index 30d76fe..58efbc1 100644 --- a/internal/web/handlers/handlers_inbound.go +++ b/internal/web/handlers/handlers_inbound.go @@ -6,6 +6,7 @@ import ( "net/http" "strconv" "sync" + "time" "github.com/mixeme/selfpost/internal/dnscheck" "github.com/mixeme/selfpost/internal/health" @@ -201,8 +202,12 @@ type inboundDetailView struct { func (h *Handlers) renderInboundDetail(w http.ResponseWriter, r *http.Request, status int, d store.InboundDomain, extra inboundDetailView) { mx := dnscheck.Result{Status: health.StatusUnknown} + var checked string if h.dns != nil && h.cfg.Hostname != "" { mx = h.dns.InboundMX(d.Name, h.cfg.Hostname, false) + if at := h.dns.InboundMXCheckedAt(d.Name, h.cfg.Hostname); !at.IsZero() { + checked = view.FormatAge(at, time.Now()) + } } on, milter, action := h.inboundFilter() page := view.NewInDomain(h.shellMeta(r), view.InDomainInput{ @@ -210,6 +215,8 @@ func (h *Handlers) renderInboundDetail(w http.ResponseWriter, r *http.Request, s RecipientMode: d.RecipientMode, RecipientCount: d.RecipientCount, Addresses: d.Recipients, }). WithMX(h.cfg.Hostname, view.DNSCheck{Status: string(mx.Status), Detail: mx.Detail, Found: mx.Records}). + WithChecked(checked). + WithLog(view.InLogHref(view.InLogFilter{Domain: d.Name}, 1)). WithFilter(on, milter, action). WithResult(inboundFlash(r), firstNonEmpty(extra.FormErr, extra.TransportErr, extra.RecipientErr)) h.view.Render(w, status, "in-domain", page) diff --git a/internal/web/handlers/handlers_monitor.go b/internal/web/handlers/handlers_monitor.go index 6f04e5c..3f23f3a 100644 --- a/internal/web/handlers/handlers_monitor.go +++ b/internal/web/handlers/handlers_monitor.go @@ -212,9 +212,13 @@ func deliveryEvents(row store.SendLogRow, policy postfix.RetryPolicy, sendsAs vi return append(events, step(row.UpdatedAt, "ok", "Delivered", "The receiving server accepted the message. That is as far as this server can see — what the recipient's mailbox then did with it is not reported back.")) case store.StatusDeferred: + // And the wait that follows, as an open dot: the next attempt is + // Postfix's, at a time the panel does not know. return append(events, step(row.UpdatedAt, "warn", "Deferred, will be retried", fmt.Sprintf("The receiving server could not take the message yet. Postfix retries: first after %s, then with increasing gaps up to %s, for up to %s. There is no fixed attempt count — a deferred message stays in the queue until it is delivered or that lifetime runs out.", - policy.FirstRetry(), policy.BackoffCap(), policy.QueueLifetime()))) + policy.FirstRetry(), policy.BackoffCap(), policy.QueueLifetime())), + view.Step{Time: view.StepTime(time.Time{}), Level: view.StepLevel("", false), + Text: view.Plain(fmt.Sprintf("Still in the queue; the next attempt is Postfix's own, and after %s it is bounced.", policy.QueueLifetime()))}) case store.StatusBounced: if row.Reason == store.ReasonReconcile { // Neither the receiving server nor Postfix said so: the panel diff --git a/internal/web/handlers/handlers_monitor_test.go b/internal/web/handlers/handlers_monitor_test.go index 3a21c0d..40c3533 100644 --- a/internal/web/handlers/handlers_monitor_test.go +++ b/internal/web/handlers/handlers_monitor_test.go @@ -565,6 +565,9 @@ func TestDeliveryPageDeferredUsesRetryPolicy(t *testing.T) { "first after 10 minutes", "up to about 1 hour 7 minutes", "for up to 2 days", + // And the wait after it, an open dot with no time (mockup + // out-message.html). + `
  • Still in the queue; the next attempt is Postfix's own, and after 2 days it is bounced.
  • `, } { if !strings.Contains(out, want) { t.Errorf("deferred history is missing %q:\n%s", want, out) diff --git a/internal/web/view/dmarc_test.go b/internal/web/view/dmarc_test.go index d9cdca4..7b81a77 100644 --- a/internal/web/view/dmarc_test.go +++ b/internal/web/view/dmarc_test.go @@ -50,7 +50,7 @@ func TestDMARCDomainStampAndLead(t *testing.T) { pageHas(t, "DMARC domain", out, `

    shop.example.org

    `, `DMARC reports /`, `class="sp-postmark sp-warn"`, `DMARC99.6%PASS · 7 DAYS`, - "11 204 pass · 41 fail in the last 7 days. A third-party source is not aligned. Do not tighten p= until that sender is fixed or removed.", + "11 204 pass · 41 fail in the last 7 days. Failures come from a source that is not this relay — review before tightening p=.", `Domain page`, `

    Sources · 7 days

    `, `203.0.113.25 this relay`, `041none`, diff --git a/internal/web/view/fixtures_test.go b/internal/web/view/fixtures_test.go index b3bc324..602acbb 100644 --- a/internal/web/view/fixtures_test.go +++ b/internal/web/view/fixtures_test.go @@ -290,8 +290,8 @@ func outMessageFixture() *OutMessage { {Time: StepTime(at), Strong: "Accepted and queued", Text: Plain(" — Postfix accepted the message over an authenticated submission and the journal-milter recorded it.")}, {Time: StepTime(at.Add(2 * time.Second)), Strong: "Deferred, will be retried", Level: LevelWarn, Text: Plain(" — the receiving server could not take the message yet. Postfix retries: first after 5 minutes, then with increasing gaps up to 1 hour 7 minutes, for up to 5 days.")}, - {Time: StepTime(time.Time{}), Strong: "Waiting for a delivery report", Level: LevelPending, - Text: Plain(" — the next attempt is made by Postfix on its own; the queue shows what it is still holding.")}, + {Time: StepTime(time.Time{}), Level: LevelPending, + Text: Plain("Still in the queue; the next attempt is Postfix's own, and after 5 days it is bounced.")}, }) return p.WithDeliveryLog([]LogLine{ {Time: "13:58:07", Text: "postfix/smtpd[2114]: 4XcB7k2Jm9z1: client=unknown[198.51.100.7], sasl_username=alerts"}, @@ -324,7 +324,7 @@ func dmarcHubFixture() *DMARCHub { // with one source that is not this relay failing, and three reports. func dmarcDomainFixture() *DMARCDomain { p := NewDMARCDomain(admin(), 2, "shop.example.org", true, 11204, 41, 7, - "A third-party source is not aligned. Do not tighten p= until that sender is fixed or removed.") + "Failures come from a source that is not this relay — review before tightening p=.") row := func(id int64, at, reporter, window string, pass, fail int) DMARCReportRow { return DMARCReportRow{Received: at, Reporter: reporter, Window: window, Pass: pass, Fail: fail, Href: DMARCReportHref(id)} } @@ -379,6 +379,7 @@ func inDomainFixture() *InDomain { TLSMode: "encrypt", RecipientMode: "list", RecipientCount: 4, Addresses: []string{"announce@lists.example.org", "dev@lists.example.org", "owner@lists.example.org", "security@lists.example.org"}}) p.WithMX("mail.example.org", DNSCheck{Status: "ok", Found: []string{"10 mail.example.org."}}) + p.WithChecked("1 min ago").WithLog("/inbound/log?domain=lists.example.org") p.WithFilter(true, "inet:antispam:11332", "accept") return p } diff --git a/internal/web/view/health.go b/internal/web/view/health.go index 136a6df..be4a51d 100644 --- a/internal/web/view/health.go +++ b/internal/web/view/health.go @@ -187,7 +187,7 @@ func NewHealth(m Meta, flash string) *Health { Head: Head{ Kicker: "Server", Title: "Health", - Lead: Plain("Everything behind the cards on Overview. Refreshes automatically."), + Lead: Plain("Everything behind the six cards on Overview. Refreshes automatically."), Actions: []Action{{Label: "Re-check DNS", Icon: "ti-refresh", Post: "/server/health/recheck"}}, }, // Numbered by importance; the odd ones are the left stack. diff --git a/internal/web/view/help.go b/internal/web/view/help.go index 699e58f..baf7644 100644 --- a/internal/web/view/help.go +++ b/internal/web/view/help.go @@ -75,6 +75,23 @@ func topicLink(topic, title string) *HelpLink { return &HelpLink{Href: "/help#" + topic, Title: title} } +// withoutTopic is a group's topics and menu without the topic id. +func withoutTopic(topics []HelpTopic, menu []MenuItem, id string) ([]HelpTopic, []MenuItem) { + var t []HelpTopic + for _, x := range topics { + if x.ID != id { + t = append(t, x) + } + } + var m []MenuItem + for _, x := range menu { + if x.Href != "#"+id { + m = append(m, x) + } + } + return t, m +} + // reachesOutbound is whether the viewer has anything in Outbound: the global // role, or a domain administrator with an outbound domain. func (m Meta) reachesOutbound() bool { return m.IsGlobal || m.HasOutbound } @@ -116,14 +133,21 @@ func NewHelp(m Meta, inbound bool) *Help { }) } if inbound && (m.IsGlobal || m.HasInbound) { - add("Inbound", inboundTopics, []MenuItem{ + topics, menu := inboundTopics, []MenuItem{ {Label: "Backup-MX and forwarding", Icon: "ti-inbox", Href: "#" + HelpInbound}, {Label: "Upstream", Icon: "ti-arrow-forward-up", Href: "#" + HelpUpstream}, {Label: "Valid recipients", Icon: "ti-address-book", Href: "#" + HelpRecipients}, {Label: "Spam filter", Icon: "ti-shield-check", Href: "#" + HelpFilter}, {Label: "Inbound log", Icon: "ti-list-details", Href: "#" + HelpInboundLog}, {Label: "Filter lists", Icon: "ti-filter", Href: "#" + HelpFilterLists}, - }) + } + // The filter lists are the whole server's and their page is 404 to + // a domain administrator (requireInboundGlobal), so their topic is + // not shown to one either. + if !m.IsGlobal { + topics, menu = withoutTopic(topics, menu, HelpFilterLists) + } + add("Inbound", topics, menu) } if m.IsGlobal { add("Server", serverTopics, []MenuItem{ @@ -174,7 +198,7 @@ var outboundTopics = []HelpTopic{ ", once the reports look clean. A domain's report address is set in its Domain settings."), }}, {ID: HelpApps, Title: "Applications", Body: []Text{ - Plain("An application is one SASL login, unique across all domains. It may send as any address of its domain or only as listed addresses, and, with a list of client IPs set, only from those addresses. The password is shown once at creation or regeneration and is not stored in readable form."), + Plain("An application is one SASL login, unique across all domains. It may send as any address of its domain or only as listed addresses, and, with a list of client IPs set, only from those addresses. The password is shown once, at creation or regeneration, and the panel does not show it again; the server keeps it, so a domain export can carry it."), }}, {ID: HelpSenderCheck, Title: "Sender check", Body: []Text{ Rich("An application sends only as itself, and that holds for every sender a message names. The envelope sender is held against the login by Postfix. The ", @@ -188,7 +212,7 @@ var outboundTopics = []HelpTopic{ Rich("Level 1 is a per-IP ceiling set in ", Code(".env"), ". Level 2 is optional, per domain and per application, entered by hand or computed from the last 30 days of sending, and can never exceed level 1. An application's own limit replaces its domain's."), }}, {ID: HelpLog, Title: "Outbound log", Body: []Text{ - Rich("One row per message and recipient, with the status Postfix last reported: queued (accepted, nothing reported yet), sent (the receiving server took it; what the mailbox does next is not reported back), deferred (not taken yet, Postfix tries again), bounced (failed for good, or the queue lifetime ran out; or closed by the panel when Postfix no longer held the message and ", Code("mail.log"), " never said what became of it) and rejected (refused before queueing, so it never reached the queue: under a level-2 rate limit, from a client address the application does not allow, or for a ", Code("From:"), " or ", Code("Sender:"), " line that is not the application's - the message's page says which). A message's page lists these steps and the lines Postfix wrote about it in ", + Rich("One row per message and recipient (a message refused for its From: or Sender: line is one row, however many recipients it had), with the status Postfix last reported: queued (accepted, nothing reported yet), sent (the receiving server took it; what the mailbox does next is not reported back), deferred (not taken yet, Postfix tries again), bounced (failed for good, or the queue lifetime ran out; or closed by the panel when Postfix no longer held the message and ", Code("mail.log"), " never said what became of it) and rejected (refused before queueing, so it never reached the queue: under a level-2 rate limit, from a client address the application does not allow, or for a ", Code("From:"), " or ", Code("Sender:"), " line that is not the application's - the message's page says which). A message's page lists these steps and the lines Postfix wrote about it in ", Code("mail.log"), ", found by queue id - and for a message refused before it had one, Postfix's one line about the refusal. The lines are kept with the row: they stay on the page after that file has rotated, for as long as the row does. A message accepted before version 2.2.0 shows them only while they are still in the current file. Rows are deleted after the retention set in Server › Settings, 90 days by default, and their lines with them. A domain administrator sees only the rows of their own domains."), }}, {ID: HelpQueue, Title: "Queue and retries", Body: []Text{ @@ -245,7 +269,7 @@ var serverTopics = []HelpTopic{ {ID: HelpBackup, Title: "Backup and moving a domain", Body: []Text{ Rich("Full backup downloads one archive of the whole instance: ", Code("data/"), " (the database, DKIM keys and application credentials), ", Code("docker-compose.yml"), ", ", Code(".env"), " and ", Code("certs/"), - ". 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."+ + ". 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; every attempt, right or wrong, counts against the limit of a password change, ten in 15 minutes."+ " 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/"), @@ -260,7 +284,7 @@ var serverTopics = []HelpTopic{ {ID: HelpUsers, Title: "Users and roles", Body: []Text{ Rich("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"), " includes domains added later, and a domain user needs at least one domain or ", Em("All"), - " 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."), + " 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 everywhere; set for yourself, it keeps the session you set it from."), }}, {ID: HelpSettings, Title: "Settings", Body: []Text{ Rich("Settings holds what is true of the whole instance. The time zone is the container's, ", Code("TZ"), " in ", Code(".env"), @@ -271,7 +295,7 @@ var serverTopics = []HelpTopic{ Code(".env"), ": changing it needs a restart, and no level-2 limit can exceed it. Notifications are turned on or off here for everyone."), }}, {ID: HelpNotifications, Title: "Notifications", Body: []Text{ - Rich("The panel writes to its users when something needs them, from ", Code("postmaster@"), " 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.", + Rich("The panel writes to its users when something needs them, from ", Code("postmaster@"), " 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 sending domain. 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(), diff --git a/internal/web/view/help_test.go b/internal/web/view/help_test.go index 5d9a1d0..0a7047e 100644 --- a/internal/web/view/help_test.go +++ b/internal/web/view/help_test.go @@ -186,6 +186,12 @@ func TestHelpHasTheInboundLogAndFilterListsTopics(t *testing.T) { if want := v.inbound && (v.meta.IsGlobal || v.meta.HasInbound); shown != want { t.Errorf("%s: inbound log topic shown = %v, want %v", v.name, shown, want) } + // The filter lists' page is the global role's alone (404 to anyone + // else), and so is their topic and its menu entry. + lists := strings.Contains(out, `id="filter-lists"`) || strings.Contains(out, `href="#filter-lists"`) + if want := v.inbound && v.meta.IsGlobal; lists != want { + t.Errorf("%s: filter lists topic shown = %v, want %v", v.name, lists, want) + } } if HelpInboundLog != "inbound-log" || HelpFilterLists != "filter-lists" { t.Errorf("anchors %q and %q", HelpInboundLog, HelpFilterLists) diff --git a/internal/web/view/inbound.go b/internal/web/view/inbound.go index c8f9f40..3ee90fb 100644 --- a/internal/web/view/inbound.go +++ b/internal/web/view/inbound.go @@ -257,6 +257,7 @@ type InDomain struct { recipientCount int recipientMode string filterOn bool + logHref string DNS Box Record Record @@ -395,6 +396,22 @@ func (p *InDomain) WithFilter(on bool, milter, action string) *InDomain { return p } +// WithChecked says, as the MX box's end slot, how long ago the record was +// checked (FormatAge; "" says nothing), as a sending domain's records do. +func (p *InDomain) WithChecked(checked string) *InDomain { + if checked != "" { + p.DNS.End = Plain("Checked " + checked) + } + return p +} + +// WithLog links the domain's rows of the Inbound log from the side menu's +// See also, as a sending domain's page links its own log. +func (p *InDomain) WithLog(href string) *InDomain { + p.logHref = href + return p +} + // WithResult shows the result of the last action, or its refusal, between the // head and the boxes. func (p *InDomain) WithResult(flash, formErr string) *InDomain { @@ -420,6 +437,11 @@ func (p *InDomain) Menu() SideMenu { {Label: "Spam filter", Icon: "ti-shield-check", Href: "#filter", Tag: filterTag(p.filterOn).Tag}, }} menu := SideMenu{Groups: []MenuGroup{here}} + if p.logHref != "" { + menu.Groups = append(menu.Groups, MenuGroup{Label: "See also", Items: []MenuItem{ + {Label: "Log for this domain", Icon: "ti-list-details", Href: p.logHref}, + }}) + } if p.IsGlobal { menu.Groups = append(menu.Groups, MenuGroup{Label: "Rarely changed", Items: []MenuItem{ {Label: "Delete domain", Icon: "ti-trash", Href: InboundDomainHref(p.id) + "/delete", Danger: true}, diff --git a/internal/web/view/inbound_domain_test.go b/internal/web/view/inbound_domain_test.go index 2ddfcde..7048cb3 100644 --- a/internal/web/view/inbound_domain_test.go +++ b/internal/web/view/inbound_domain_test.go @@ -17,9 +17,10 @@ func TestInDomainShowsTheMockupsBoxes(t *testing.T) { ``, `name="addresses"`, "announce@lists.example.org\ndev@lists.example.org\nowner@lists.example.org\nsecurity@lists.example.org", `

    Spam filter

    `, `href="#filter"`, `href="/inbound/domains/1/delete"`, `Delete domain`, + `Checked 1 min ago`, `Log for this domain`, ) // What has no source in this build is not drawn. - pageLacks(t, "Inbound domain", out, `rspamd`, `quarantine`, `Quarantine`, `Log for this domain`, `/inbound/quarantine`, `Allow and deny`) + pageLacks(t, "Inbound domain", out, `rspamd`, `quarantine`, `Quarantine`, `/inbound/quarantine`, `Allow and deny`) } // The Spam filter box states what the server is set up with, and what of it the diff --git a/internal/web/view/overview.go b/internal/web/view/overview.go index e1da735..6816dcb 100644 --- a/internal/web/view/overview.go +++ b/internal/web/view/overview.go @@ -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.Local().Format("15:04:05")}, + Note: []string{"Updated " + updated.Local().Format("15:04:05"), "refreshes on its own"}, } if len(bad) > 0 { lead := Text{{Text: "Needs attention: "}} diff --git a/internal/web/view/pages_test.go b/internal/web/view/pages_test.go index f78490d..c5ace53 100644 --- a/internal/web/view/pages_test.go +++ b/internal/web/view/pages_test.go @@ -63,7 +63,7 @@ func TestOverviewHeadFollowsTheCards(t *testing.T) { `

    Running, with warnings.

    `, `class="sp-postmark sp-warn"`, `WARN`, `1 OF 6 CHECKS`, `Needs attention: TLS certificate (Expires in 12 days).`, - `Updated 14:02:31`, `

    mail.example.org

    `, + `Updated 14:02:31
    refreshes on its own`, `

    mail.example.org

    `, `class="sp-warn"`, // the certificate card `href="/server/health"`, `>All details`, )