audit 4: a deleted domain says so, the Outbound log filters by status and day
test / test (push) Canceled after 0s

L17: past DeleteDomain both rebuilds run, their errors joined under
domain.ErrDeletedIncomplete; the key is removed only when OpenDKIM's tables
were rebuilt (they may still name it otherwise); the domain list says the
domain is deleted and that Reload configuration finishes it, not "internal
error". Tests in domain (the tables failing, the sender map failing) and
handlers.

The Outbound log's status and day filters (owner, 2026-10-13; the mockup
first, 80e3979): view.OutLogFilter carried by the poll, the pages, the rows'
links and the message's way back; SendLogFilter.Status, Since, Until (whole
UTC days, as the Inbound log's); rejected is the three refusals together; a
status not in the list is no narrowing. Tests: the filters through the
handler, the poll and the way back; the day boundaries in store.

The plan's milter-socket step is ticked: done in 2.5.2 (98e09fe).

Tested on the test server: go vet + go test of 25 packages, make e2e (all
pass, 642 s) on this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-15 11:34:24 +03:00
co-authored by Claude Opus 5.5
parent 80e3979748
commit 2b8e0c086d
15 changed files with 378 additions and 52 deletions
+14
View File
@@ -5,6 +5,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
## [Unreleased] ## [Unreleased]
### Added
- **The Outbound log filters by status and by day**, beside domain and
application: *queued*, *sent*, *deferred*, *bounced* or *rejected* (the
three kinds of refusal together), and from and to a day, as the Inbound
log does. The filters survive the pages, the refresh and the way back
from a message.
### Changed ### Changed
- **Reload configuration writes everything a restore's first start writes.** - **Reload configuration writes everything a restore's first start writes.**
@@ -51,6 +59,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
- **Recalculate rate limit dropped what was typed on the page.** The button - **Recalculate rate limit dropped what was typed on the page.** The button
leaves the page; when a field holds an edit not saved yet, it now asks leaves the page; when a field holds an edit not saved yet, it now asks
first (audit 2026-10-12, L19). first (audit 2026-10-12, L19).
- **Deleting a domain could leave its applications able to send.** When
OpenDKIM's tables failed to rebuild after the domain's row was gone, the
sender map was not rebuilt either and kept the deleted applications'
logins, and the page said "internal error". Every rebuild now runs, and
the page says the domain is deleted and that Reload configuration
finishes the rest (audit 2026-10-12, L17).
## [2.5.3] - 2026-10-15 ## [2.5.3] - 2026-10-15
+24 -3
View File
@@ -1237,9 +1237,14 @@ command whose leftover is the owner's alone.
`HandleReload` covers what the restore's Resync covers, the filter lists `HandleReload` covers what the restore's Resync covers, the filter lists
and the DMARC maps included, and a failed step is an error flash on and the DMARC maps included, and a failed step is an error flash on
Health naming it. Health naming it.
- [ ] `domain.Service.Delete`: say the domain is deleted when a later step fails, - [x] `domain.Service.Delete`: say the domain is deleted when a later step fails,
and run `apps.Resync()` anyway. — **Opus** and run `apps.Resync()` anyway. — **Opus**
- [ ] **A status filter and a day filter in the Outbound log** (owner, 2026-10-13, from the *Done 2026-10-15:* past `DeleteDomain` both rebuilds run and the errors
join under `domain.ErrDeletedIncomplete`; the key is removed only when
the tables were rebuilt (they may still name it otherwise). The domain
list says "Domain X deleted, but …; run Reload configuration" (500) -
which since the step above rewrites everything.
- [x] **A status filter and a day filter in the Outbound log** (owner, 2026-10-13, from the
Not done list of from-header-binding). The mockup `out-log` has drawn a Not done list of from-header-binding). The mockup `out-log` has drawn a
third list since the redesign and the built log never had it; the Inbound third list since the redesign and the built log never had it; the Inbound
log has its counterpart built (domain, decision, day). One list: *Any log has its counterpart built (domain, decision, day). One list: *Any
@@ -1251,7 +1256,16 @@ command whose leftover is the owner's alone.
commit). And a filter by day, as the Inbound log has it (owner, the same commit). And a filter by day, as the Inbound log has it (owner, the same
day): the same control, drawn in the mockup first. Both filters survive day): the same control, drawn in the mockup first. Both filters survive
paging and the rows' polling. - **Sonnet** paging and the rows' polling. - **Sonnet**
- [ ] **Milter sockets: "Listening" is asked of the socket** (owner, *Done 2026-10-15:* `design:` commit (the list corrected, the two dates),
then `view.OutLogFilter` (domain, app, status, from, to) carried by the
poll, the pages, the rows' links and the message's way back;
`SendLogFilter.Status`, `Since`, `Until` (whole UTC days, as the
Inbound log's). A status not in the list is no narrowing. Plans: a
domain user's filtered page still goes by `idx_send_log_domain_id`; the
global role's status-only count walks the table (a page view or a 5 s
poll while the log is open on a status - tens of thousands of rows at
the retention limit, milliseconds), accepted.
- [x] **Milter sockets: "Listening" is asked of the socket** (owner,
2026-10-13, from the Not done list of from-header-binding). 2026-10-13, from the Not done list of from-header-binding).
`health.CheckSocket` ([socket.go](../internal/health/socket.go)) stats the `health.CheckSocket` ([socket.go](../internal/health/socket.go)) stats the
file and writes "Listening"; a process that died and left its socket file and writes "Listening"; a process that died and left its socket
@@ -1262,6 +1276,13 @@ command whose leftover is the owner's alone.
byte ends the sender check (security.md § Accepted risks: the milter byte ends the sender check (security.md § Accepted risks: the milter
library panics on an empty packet) - if it does, that is fixed first, in library panics on an empty packet) - if it does, that is fixed first, in
this step. A test with a socket file nobody listens on. - **Opus** this step. A test with a socket file nobody listens on. - **Opus**
*Done in 2.5.2 (stage 2c, `98e09fe`), ticked here 2026-10-15:*
`health.CheckSocket` dials and closes; a refused connection says nobody
answers, a refused permission is Unknown; a connection closed without a
byte ends a milter session quietly on both sides (libmilter's
`mi_rd_cmd`, go-milter v0.4.1 `HandleMilterCommands` on `io.EOF` - the
comment on CheckSocket cites them). The socket nobody listens on is
`milter_test.go`.
- [ ] **The `cards` layout takes its phone order from the box's number** - [ ] **The `cards` layout takes its phone order from the box's number**
(owner, 2026-10-13, from the Not done list of from-header-binding). The (owner, 2026-10-13, from the Not done list of from-header-binding). The
order on a phone is written in `panel.css` for four boxes to a stack order on a phone is written in `panel.css` for four boxes to a stack
+6 -3
View File
@@ -1389,9 +1389,12 @@ the only one of the two that does not depend on the panel process.
### Outbound log ### Outbound log
**Outbound › Log** (`/outbound/log`) is a searchable send log with server-side **Outbound › Log** (`/outbound/log`) is a searchable send log with server-side
filters by domain and application; a user with the *domain* role sees only the filters by domain, application, status and day (from and to, UTC days, as on
rows of their domains. It refreshes its rows without resetting a filter being the Inbound log); the status *rejected* is the three kinds of refusal
chosen. A row identifies its message and nothing more — time, 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
without resetting a filter being chosen. A row identifies its message and nothing more — time,
sender, recipient, subject and status `queued` (accepted, not yet sender, recipient, subject and status `queued` (accepted, not yet
delivered), `sent` (handed off successfully), `deferred` (Postfix is delivered), `sent` (handed off successfully), `deferred` (Postfix is
retrying), `bounced` (final failure: refused for good by the receiving server, or given retrying), `bounced` (final failure: refused for good by the receiving server, or given
+1 -1
View File
@@ -42,7 +42,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
| from-header-binding | Hold a message's `From:` header against the application that sends it | shipped `2.4.0` | 9/9 | [plans/from-header-binding.md](plans/from-header-binding.md) | | from-header-binding | Hold a message's `From:` header against the application that sends it | shipped `2.4.0` | 9/9 | [plans/from-header-binding.md](plans/from-header-binding.md) |
| panel-redesign | Panel redesign (accepted mockups, design contract) | shipped `2.0.0` | 27/27 | [plans/panel-redesign.md](plans/panel-redesign.md) | | panel-redesign | Panel redesign (accepted mockups, design contract) | shipped `2.0.0` | 27/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |
| schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [plans/panel-redesign.md](plans/panel-redesign.md) | | schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [plans/panel-redesign.md](plans/panel-redesign.md) |
| audit-2026-10-12 | Follow-ups of the 2026-10-12 full-tree code audit, seven stages | in progress (stage 0 shipped `2.4.2`, stages 1+2 `2.5.0`, 2b `2.5.1`, 2c `2.5.2`, 3 `2.5.3`; next stage 4 → `2.5.4`) | 59/81 | [audit-2026-10-12.md](audit-2026-10-12.md) | | audit-2026-10-12 | Follow-ups of the 2026-10-12 full-tree code audit, seven stages | in progress (stage 0 shipped `2.4.2`, stages 1+2 `2.5.0`, 2b `2.5.1`, 2c `2.5.2`, 3 `2.5.3`; next stage 4 → `2.5.4`) | 62/81 | [audit-2026-10-12.md](audit-2026-10-12.md) |
**Recommended order** (not binding; owner, 2026-10-09: feature order follows **Recommended order** (not binding; owner, 2026-10-09: feature order follows
development convenience): **panel-redesign** shipped as `2.0.0` (2026-10-11) and development convenience): **panel-redesign** shipped as `2.0.0` (2026-10-11) and
+71
View File
@@ -0,0 +1,71 @@
package domain
import (
"errors"
"os"
"path/filepath"
"testing"
"github.com/mixeme/selfpost/internal/store"
)
// countingApps is fakeApps that counts the sender map's rebuilds and can
// fail them.
type countingApps struct {
fakeApps
resyncs int
resyncErr error
}
func (c *countingApps) Resync() error {
c.resyncs++
return c.resyncErr
}
// A deletion that fails after the domain's row is gone says the domain is
// deleted, and runs every rebuild anyway: the sender map was left naming the
// purged logins when OpenDKIM's tables failed first (audit 2026-10-12, L17).
// The key stays while the tables may still name it.
func TestDeleteRunsEveryRebuildAndSaysTheDomainIsGone(t *testing.T) {
apps := &countingApps{}
s, odk := newTestService(t, apps)
d, err := s.Add("gone.example")
if err != nil {
t.Fatal(err)
}
keyDir := filepath.Join(odk.keysDir, d.Name)
apps.resyncs = 0
odk.reload = func() error { return errors.New("supervisor away") }
err = s.Delete(d.ID)
if !errors.Is(err, ErrDeletedIncomplete) {
t.Fatalf("Delete with OpenDKIM's reload failing = %v, want ErrDeletedIncomplete", err)
}
if _, err := s.Get(d.ID); !errors.Is(err, store.ErrDomainNotFound) {
t.Errorf("the domain is still in the registry: %v", err)
}
if apps.resyncs != 1 {
t.Errorf("the sender map was rebuilt %d times, want once", apps.resyncs)
}
if _, err := os.Stat(keyDir); err != nil {
t.Errorf("the key went while the tables may still name it: %v", err)
}
}
// The sender map failing is the same: deleted, said so; the tables were
// rebuilt, so the key goes.
func TestDeleteWithTheSenderMapFailing(t *testing.T) {
apps := &countingApps{resyncErr: errors.New("postfix away")}
s, odk := newTestService(t, apps)
d, err := s.Add("gone.example")
if err != nil {
t.Fatal(err)
}
keyDir := filepath.Join(odk.keysDir, d.Name)
if err := s.Delete(d.ID); !errors.Is(err, ErrDeletedIncomplete) {
t.Fatalf("Delete with the sender map failing = %v, want ErrDeletedIncomplete", err)
}
if _, err := os.Stat(keyDir); !os.IsNotExist(err) {
t.Errorf("the key is still there after the tables were rebuilt: %v", err)
}
}
+23 -9
View File
@@ -1,6 +1,7 @@
package domain package domain
import ( import (
"errors"
"fmt" "fmt"
"github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/store"
@@ -202,28 +203,41 @@ func (s *Service) Delete(id int64) error {
if err := s.store.DeleteDomain(id); err != nil { if err := s.store.DeleteDomain(id); err != nil {
return err return err
} }
// From here the domain is deleted, whatever fails after: each rebuild
// runs - the sender map was left with the purged logins when OpenDKIM's
// failed first - and a failure says the domain is gone (audit
// 2026-10-12, L17).
var errs []error
tablesRebuilt := true
if err := s.resync(); err != nil { if err := s.resync(); err != nil {
return err tablesRebuilt = false
errs = append(errs, fmt.Errorf("rebuild OpenDKIM's tables: %w", err))
} }
if err := s.apps.Resync(); err != nil { if err := s.apps.Resync(); err != nil {
return fmt.Errorf("rebuild sender map after deleting %s: %w", d.Name, err) errs = append(errs, fmt.Errorf("rebuild the sender map: %w", err))
} }
// A sending domain named like the server shared its key with the server's // A sending domain named like the server shared its key with the server's
// own mail. The resync above kept the name in the tables under the default // own mail. The resync above kept the name in the tables under the default
// selector: the same key if that was the domain's selector, a new one - // selector: the same key if that was the domain's selector, a new one -
// with a new record to publish, which Settings shows - if it was not. // with a new record to publish, which Settings shows - if it was not.
// Either way the directory holds the key now in use. // Either way the directory holds the key now in use. And while the tables
if d.Name == s.serverName { // may still name the deleted domain's key, the key stays.
return nil if d.Name != s.serverName && tablesRebuilt {
if err := s.odk.RemoveKey(d.Name); err != nil {
errs = append(errs, fmt.Errorf("remove its DKIM key: %w", err))
}
} }
if err := s.odk.RemoveKey(d.Name); err != nil { if len(errs) > 0 {
// The domain is gone from the registry and tables; a leftover key return fmt.Errorf("%w (%s): %w", ErrDeletedIncomplete, d.Name, errors.Join(errs...))
// directory is harmless. Surface it so it is not silently ignored.
return fmt.Errorf("domain deleted but key cleanup failed: %w", err)
} }
return nil return nil
} }
// ErrDeletedIncomplete is a domain deleted from the registry, with a step
// after it that failed: the mail configuration still names it until a
// rebuild succeeds (Reload configuration).
var ErrDeletedIncomplete = errors.New("domain deleted, but the mail configuration was not fully rewritten after it")
// DKIMRecord returns the DNS TXT record to publish for a domain (product.md). // DKIMRecord returns the DNS TXT record to publish for a domain (product.md).
func (s *Service) DKIMRecord(d store.Domain) (DKIMRecord, error) { func (s *Service) DKIMRecord(d store.Domain) (DKIMRecord, error) {
return s.odk.Record(d.Name, d.DKIMSelector) return s.odk.Record(d.Name, d.DKIMSelector)
+45
View File
@@ -3,6 +3,7 @@ package store
import ( import (
"strings" "strings"
"testing" "testing"
"time"
) )
// explain returns SQLite's plan for query, one step a line. // explain returns SQLite's plan for query, one step a line.
@@ -111,3 +112,47 @@ func TestSendLogQueriesUseTheirIndexes(t *testing.T) {
} }
} }
} }
// The Outbound log's days are whole UTC days: from the first's midnight up
// to, not including, midnight after the last; the status is a row's.
func TestSendLogFilterDaysAndStatus(t *testing.T) {
s := openTestStore(t)
stamps := []string{"2026-09-13T23:59:59Z", "2026-09-14T00:00:00Z", "2026-09-21T23:59:59Z", "2026-09-22T00:00:00Z"}
for i, at := range stamps {
if err := s.InsertQueued(SendLogEntry{QueueID: "Q" + at[:2] + string(rune('A'+i)), Domain: "d.example", From: "a@d.example", To: "b@x.example"}); err != nil {
t.Fatal(err)
}
if _, err := s.db.Exec(`UPDATE send_log SET created_at = ? WHERE id = (SELECT MAX(id) FROM send_log)`, at); err != nil {
t.Fatal(err)
}
}
if _, err := s.db.Exec(`UPDATE send_log SET status = 'sent' WHERE created_at = '2026-09-14T00:00:00Z'`); err != nil {
t.Fatal(err)
}
day := func(d int) time.Time { return time.Date(2026, 9, d, 15, 0, 0, 0, time.FixedZone("x", 3*3600)) }
got := func(f SendLogFilter) []string {
f.AllDomains = true
rows, err := s.QuerySendLog(f, 10, 0)
if err != nil {
t.Fatal(err)
}
n, err := s.CountSendLog(f)
if err != nil || n != int64(len(rows)) {
t.Fatalf("count %d, %v; rows %d", n, err, len(rows))
}
var out []string
for _, r := range rows {
out = append(out, r.CreatedAt.UTC().Format(time.RFC3339))
}
return out
}
if g := got(SendLogFilter{Since: day(14), Until: day(21)}); strings.Join(g, " ") != "2026-09-21T23:59:59Z 2026-09-14T00:00:00Z" {
t.Errorf("14 to 21 = %v", g)
}
if g := got(SendLogFilter{Status: StatusSent}); strings.Join(g, " ") != "2026-09-14T00:00:00Z" {
t.Errorf("sent = %v", g)
}
if g := got(SendLogFilter{Status: StatusQueued, Until: day(13)}); strings.Join(g, " ") != "2026-09-13T23:59:59Z" {
t.Errorf("queued until 13 = %v", g)
}
}
+21
View File
@@ -430,6 +430,11 @@ type SendLogFilter struct {
AppLogin string AppLogin string
DomainIDs []int64 DomainIDs []int64
AllDomains bool 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.
// Zero values do not narrow.
Status string
Since, Until time.Time
} }
// sendLogPageQuery is a page of the rows where selects, newest first. A page // sendLogPageQuery is a page of the rows where selects, newest first. A page
@@ -508,6 +513,22 @@ func sendLogWhere(f SendLogFilter) (string, []any) {
clauses = append(clauses, "app_login = ?") clauses = append(clauses, "app_login = ?")
args = append(args, f.AppLogin) args = append(args, f.AppLogin)
} }
if f.Status != "" {
clauses = append(clauses, "status = ?")
args = append(args, f.Status)
}
// 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()
clauses = append(clauses, "created_at >= ?")
args = append(args, time.Date(y, m, d, 0, 0, 0, 0, time.UTC).Format(time.RFC3339))
}
if !f.Until.IsZero() {
y, m, d := f.Until.UTC().Date()
clauses = append(clauses, "created_at < ?")
args = append(args, time.Date(y, m, d+1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339))
}
if len(clauses) == 0 { if len(clauses) == 0 {
return "", nil return "", nil
} }
@@ -6,6 +6,7 @@ import (
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"regexp" "regexp"
"strconv"
"strings" "strings"
"testing" "testing"
@@ -329,3 +330,25 @@ func TestReloadWritesTheDMARCMapsAndSaysWhereItFailed(t *testing.T) {
} }
has(t, "failed reload", rec.Body.String(), "Reload failed at the DMARC report maps", "Reload configuration") has(t, "failed reload", rec.Body.String(), "Reload failed at the DMARC report maps", "Reload configuration")
} }
// A domain deleted with a rebuild failing after is said to be deleted, with
// the way to finish, not "internal error" (audit 2026-10-12, L17).
func TestDeleteDomainSaysItIsGoneWhenARebuildFails(t *testing.T) {
s, _ := hostedStand(t)
odk := domain.NewOpenDKIM(t.TempDir())
odk.SetReloadHook(func() error { return nil })
s.h.domains = domain.NewService(s.h.store, odk, s.h.apps, "mail")
d, err := s.h.domains.Add("gone.example")
if err != nil {
t.Fatal(err)
}
odk.SetReloadHook(func() error { return errors.New("supervisor away") })
rec := postFormAs(s.h.HandleDeleteDomain, globalPrincipal, "/outbound/domains/x/delete", map[string]string{"id": strconv.FormatInt(d.ID, 10)}, url.Values{})
if rec.Code != http.StatusInternalServerError {
t.Fatalf("delete with a failing rebuild = %d", rec.Code)
}
has(t, "delete", rec.Body.String(), "Domain gone.example deleted, but the mail configuration was not fully rewritten", "Reload configuration")
if _, err := s.h.store.GetDomain(d.ID); !errors.Is(err, store.ErrDomainNotFound) {
t.Errorf("the domain is still there: %v", err)
}
}
@@ -8,6 +8,7 @@ import (
"sync" "sync"
"time" "time"
"github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/health" "github.com/mixeme/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/validate" "github.com/mixeme/selfpost/internal/web/validate"
@@ -147,7 +148,9 @@ func (h *Handlers) HandleDeleteDomain(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return return
} }
name := ""
if d, err := h.domains.Get(id); err == nil { if d, err := h.domains.Get(id); err == nil {
name = d.Name
defer h.dns.Forget(d.Name) defer h.dns.Forget(d.Name)
} }
if err := h.domains.Delete(id); err != nil { if err := h.domains.Delete(id); err != nil {
@@ -156,6 +159,11 @@ func (h *Handlers) HandleDeleteDomain(w http.ResponseWriter, r *http.Request) {
return return
} }
logf("panel: delete domain %d: %v", id, err) logf("panel: delete domain %d: %v", id, err)
if errors.Is(err, domain.ErrDeletedIncomplete) {
h.renderDashboard(w, r, http.StatusInternalServerError,
"Domain "+name+" deleted, but the mail configuration was not fully rewritten after it. Run Reload configuration on Server › Health; the panel's log says what failed.", "")
return
}
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return return
} }
+22 -2
View File
@@ -6,6 +6,7 @@ import (
"io/fs" "io/fs"
"net/http" "net/http"
"net/url" "net/url"
"slices"
"sort" "sort"
"strconv" "strconv"
"time" "time"
@@ -301,7 +302,7 @@ func logLines(lines []string) []view.LogLine {
func deliveriesBackURL(r *http.Request) string { func deliveriesBackURL(r *http.Request) string {
q := r.URL.Query() q := r.URL.Query()
back := url.Values{} back := url.Values{}
for _, k := range []string{"domain", "app", "p"} { for _, k := range []string{"domain", "app", "status", "from", "to", "p"} {
if v := q.Get(k); v != "" { if v := q.Get(k); v != "" {
back.Set(k, v) back.Set(k, v)
} }
@@ -364,6 +365,7 @@ func (h *Handlers) outLogPage(r *http.Request) (*view.OutLog, error) {
filter := store.SendLogFilter{ filter := store.SendLogFilter{
Domain: q.Get("domain"), Domain: q.Get("domain"),
AppLogin: q.Get("app"), AppLogin: q.Get("app"),
Status: outLogStatus(q.Get("status")),
// A global administrator reads the whole journal, including rows left // A global administrator reads the whole journal, including rows left
// behind by a domain that has since been deleted; anyone else the rows // behind by a domain that has since been deleted; anyone else the rows
// of the domains assigned to them, by id - not those an earlier domain // of the domains assigned to them, by id - not those an earlier domain
@@ -379,6 +381,15 @@ func (h *Handlers) outLogPage(r *http.Request) (*view.OutLog, error) {
filter.AppLogin = "" filter.AppLogin = ""
} }
} }
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.
if t, err := time.Parse(logDay, q.Get("from")); err == nil {
form.From, filter.Since = t.Format(logDay), t
}
if t, err := time.Parse(logDay, q.Get("to")); err == nil {
form.To, filter.Until = t.Format(logDay), t
}
total, err := h.store.CountSendLog(filter) total, err := h.store.CountSendLog(filter)
if err != nil { if err != nil {
@@ -396,7 +407,7 @@ func (h *Handlers) outLogPage(r *http.Request) (*view.OutLog, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
out := view.NewOutLog(h.shellMeta(r), h.sendLogRetentionDays(), p.IsGlobal(), domainNames, logins, filter.Domain, filter.AppLogin) out := view.NewOutLog(h.shellMeta(r), h.sendLogRetentionDays(), p.IsGlobal(), domainNames, logins, form)
table := make([]view.OutLogRow, len(rows)) table := make([]view.OutLogRow, len(rows))
for i, row := range rows { for i, row := range rows {
table[i] = view.OutLogRow{ table[i] = view.OutLogRow{
@@ -408,6 +419,15 @@ func (h *Handlers) outLogPage(r *http.Request) (*view.OutLog, error) {
return out.WithRows(table, page, lastPage), nil return out.WithRows(table, page, lastPage), nil
} }
// outLogStatus is the status filter of the Outbound log: one of the statuses
// a row has, or none.
func outLogStatus(v string) string {
if slices.Contains(view.OutLogStatuses, v) {
return v
}
return ""
}
// parsePage clamps the "p" query parameter to a valid page number, defaulting // parsePage clamps the "p" query parameter to a valid page number, defaulting
// to 1 for anything missing or malformed rather than rejecting the request. // to 1 for anything missing or malformed rather than rejecting the request.
// //
@@ -3,8 +3,10 @@ package handlers
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest"
"strings" "strings"
"testing" "testing"
"time"
"github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/store"
) )
@@ -228,3 +230,58 @@ func TestMessagePageIsNotFoundForOtherTenants(t *testing.T) {
} }
} }
} }
// The status filter narrows to the statuses of a row, rejected being all
// three refusals; the days narrow to the rows of those days; both survive
// the paging, the poll and the way back from a message (owner, 2026-10-13).
func TestOutLogFiltersByStatusAndDay(t *testing.T) {
h, _ := serverWithDelivery(t) // one sent row of bs.example.ru, by Queuer3C
for _, reason := range []string{store.ReasonLimit, store.ReasonClient, store.ReasonFrom} {
if err := h.store.InsertRejected(store.SendLogEntry{
Domain: "bs.example.ru", AppLogin: "Queuer3C", From: "noreply@bs.example.ru", To: "public@example.ru",
Subject: "Refused " + reason, Reason: reason,
}); err != nil {
t.Fatal(err)
}
}
rejected := getBody(t, h.HandleDeliveries, "/outbound/log?status=rejected")
for _, reason := range []string{store.ReasonLimit, store.ReasonClient, store.ReasonFrom} {
has(t, "rejected", rejected, "Refused "+reason)
}
has(t, "rejected", rejected, `<option value="rejected" selected>rejected</option>`)
if got := strings.Count(rejected, `">Details</a>`); got != 3 {
t.Errorf("status=rejected shows %d rows, want the three refusals", got)
}
sent := getBody(t, h.HandleDeliveries, "/outbound/log?status=sent")
if got := strings.Count(sent, `">Details</a>`); got != 1 {
t.Errorf("status=sent shows %d rows, want 1", got)
}
// A status that is not one is no narrowing, not an empty log.
if got := strings.Count(getBody(t, h.HandleDeliveries, "/outbound/log?status=rate-limited"), `">Details</a>`); got != 4 {
t.Errorf("status=rate-limited shows %d rows, want all 4", got)
}
today := time.Now().UTC().Format("2006-01-02")
tomorrow := time.Now().UTC().AddDate(0, 0, 1).Format("2006-01-02")
yesterday := time.Now().UTC().AddDate(0, 0, -1).Format("2006-01-02")
if got := strings.Count(getBody(t, h.HandleDeliveries, "/outbound/log?from="+today+"&to="+today), `">Details</a>`); got != 4 {
t.Errorf("today shows %d rows, want 4", got)
}
for _, q := range []string{"from=" + tomorrow, "to=" + yesterday} {
if body := getBody(t, h.HandleDeliveries, "/outbound/log?"+q); strings.Contains(body, `">Details</a>`) {
t.Errorf("%s shows rows from today", q)
}
}
body := getBody(t, h.HandleDeliveries, "/outbound/log?status=rejected&from="+today)
has(t, "filtered", body, `name="from" value="`+today+`"`,
`hx-get="/outbound/log/fragment?from=`+today+`&amp;status=rejected"`,
`?from=`+today+`&amp;status=rejected">Details</a>`)
frag := getBody(t, h.HandleDeliveriesRows, "/outbound/log/fragment?status=rejected&from="+today)
if got := strings.Count(frag, `">Details</a>`); got != 3 {
t.Errorf("the poll of status=rejected shows %d rows, want 3", got)
}
// The message's way back keeps the filters.
if got := deliveriesBackURL(httptest.NewRequest("GET", "/outbound/log/1?status=rejected&from="+today+"&to="+today+"&p=2", nil)); got != "/outbound/log?from="+today+"&p=2&status=rejected&to="+today {
t.Errorf("way back = %q", got)
}
}
+1 -1
View File
@@ -252,7 +252,7 @@ func outDomainDeleteFixture() *OutDomainDelete {
// the first of 412 pages, one deferred, one bounced, one refused by a rate limit. // the first of 412 pages, one deferred, one bounced, one refused by a rate limit.
func outLogFixture() *OutLog { func outLogFixture() *OutLog {
p := NewOutLog(admin(), 30, true, p := NewOutLog(admin(), 30, true,
[]string{"example.org", "shop.example.org", "notify.acme.io"}, []string{"prod-server", "alerts"}, "", "") []string{"example.org", "shop.example.org", "notify.acme.io"}, []string{"prod-server", "alerts"}, OutLogFilter{})
// status is a row's status, or for a refused row "rejected:" and its reason. // status is a row's status, or for a refused row "rejected:" and its reason.
row := func(id int64, at, from, to, subject, status string) OutLogRow { row := func(id int64, at, from, to, subject, status string) OutLogRow {
status, reason, _ := strings.Cut(status, ":") status, reason, _ := strings.Cut(status, ":")
+41 -21
View File
@@ -20,16 +20,32 @@ import (
const outboundLog = "/outbound/log" const outboundLog = "/outbound/log"
// logQuery is the query string of a page of the log: the filters and the page, // OutLogFilter is what the filters of the Outbound log are set to, as the
// strings the form sends: a domain, an application, a status of a row
// (OutLogStatuses) and two days (2006-01-02). Empty means no narrowing.
type OutLogFilter struct {
Domain string
App string
Status string
From string
To string
}
// OutLogStatuses are the statuses a row of the log has, the choices of the
// status filter: rejected is the three kinds of refusal together - a limit,
// a client address, a From: line - which the table tells apart by their tags
// (owner, 2026-10-13: the list without the split).
var OutLogStatuses = []string{"queued", "sent", "deferred", "bounced", "rejected"}
// query is the query string of a page of the log: the filters and the page,
// each left out when it says nothing, so the first unfiltered page is the bare // each left out when it says nothing, so the first unfiltered page is the bare
// path. // path.
func logQuery(domain, app string, page int) string { func (f OutLogFilter) query(page int) string {
q := url.Values{} q := url.Values{}
if domain != "" { for name, v := range map[string]string{"domain": f.Domain, "app": f.App, "status": f.Status, "from": f.From, "to": f.To} {
q.Set("domain", domain) if v != "" {
} q.Set(name, v)
if app != "" { }
q.Set("app", app)
} }
if page > 1 { if page > 1 {
q.Set("p", strconv.Itoa(page)) q.Set("p", strconv.Itoa(page))
@@ -41,8 +57,8 @@ func logQuery(domain, app string, page int) string {
} }
// LogHref is the address of a page of the Outbound log. // LogHref is the address of a page of the Outbound log.
func LogHref(domain, app string, page int) string { func LogHref(f OutLogFilter, page int) string {
return outboundLog + logQuery(domain, app, page) return outboundLog + f.query(page)
} }
// FormatLogTime is the time of a row of the log: month, day and the time of day // FormatLogTime is the time of a row of the log: month, day and the time of day
@@ -69,7 +85,7 @@ type OutLog struct {
Empty EmptyState Empty EmptyState
Foot Foot Foot Foot
domain, app string filter OutLogFilter
} }
// OutLogRow is one message of the table: when it was accepted, who it was from // OutLogRow is one message of the table: when it was accepted, who it was from
@@ -85,10 +101,9 @@ type OutLogRow struct {
} }
// NewOutLog builds the log for a viewer. domains and apps are the choices of // NewOutLog builds the log for a viewer. domains and apps are the choices of
// the two filters (only what the viewer reaches), domain and app the ones in // those two filters (only what the viewer reaches), f the filters in force. retentionDays is how long rows are kept; canSettings, the global role,
// force. retentionDays is how long rows are kept; canSettings, the global role,
// adds the way to change it. // adds the way to change it.
func NewOutLog(m Meta, retentionDays int, canSettings bool, domains, apps []string, domain, app string) *OutLog { func NewOutLog(m Meta, retentionDays int, canSettings bool, domains, apps []string, f OutLogFilter) *OutLog {
m.Title, m.Section, m.Page = "Outbound log", "outbound", "log" m.Title, m.Section, m.Page = "Outbound log", "outbound", "log"
lead := Rich("Every message an application handed to the relay. Rows older than " + plural(int64(retentionDays), "day", "days") + " are deleted") lead := Rich("Every message an application handed to the relay. Rows older than " + plural(int64(retentionDays), "day", "days") + " are deleted")
if canSettings { if canSettings {
@@ -108,21 +123,26 @@ func NewOutLog(m Meta, retentionDays int, canSettings bool, domains, apps []stri
Messages: Box{No: "01", Title: "Messages", Help: topicLink(HelpLog, "What the statuses mean"), Filter: &Filter{ Messages: Box{No: "01", Title: "Messages", Help: topicLink(HelpLog, "What the statuses mean"), Filter: &Filter{
Action: outboundLog, Action: outboundLog,
Selects: []Select{ Selects: []Select{
{Name: "domain", Label: "Domain", Options: options("All domains", domains), Selected: domain}, {Name: "domain", Label: "Domain", Options: options("All domains", domains), Selected: f.Domain},
{Name: "app", Label: "Application", Options: options("All applications", apps), Selected: app}, {Name: "app", Label: "Application", Options: options("All applications", apps), Selected: f.App},
{Name: "status", Label: "Status", Options: options("Any status", OutLogStatuses), Selected: f.Status},
},
Dates: []DateInput{
{Name: "from", Label: "From date", Value: f.From},
{Name: "to", Label: "To date", Value: f.To},
}, },
}}, }},
Empty: EmptyState{Icon: "ti-list-details", Text: Plain("No messages logged yet.")}, Empty: EmptyState{Icon: "ti-list-details", Text: Plain("No messages logged yet.")},
PollURL: outboundLog + "/fragment" + logQuery(domain, app, 1), PollURL: outboundLog + "/fragment" + f.query(1),
Foot: Foot{ID: "out-log-foot", End: Plain("New rows appear on their own")}, Foot: Foot{ID: "out-log-foot", End: Plain("New rows appear on their own")},
domain: domain, app: app, filter: f,
} }
} }
// DetailHref is the address of one message's page, carrying the filters and the // DetailHref is the address of one message's page, carrying the filters and the
// page it is opened from so that its way back returns to the same list. // page it is opened from so that its way back returns to the same list.
func (p *OutLog) DetailHref(id int64, page int) string { func (p *OutLog) DetailHref(id int64, page int) string {
return outboundLog + "/" + strconv.FormatInt(id, 10) + logQuery(p.domain, p.app, page) return outboundLog + "/" + strconv.FormatInt(id, 10) + p.filter.query(page)
} }
// WithRows sets the table for page of last, and the foot under it: where the // WithRows sets the table for page of last, and the foot under it: where the
@@ -130,17 +150,17 @@ func (p *OutLog) DetailHref(id int64, page int) string {
// page again. // page again.
func (p *OutLog) WithRows(rows []OutLogRow, page, last int) *OutLog { func (p *OutLog) WithRows(rows []OutLogRow, page, last int) *OutLog {
p.Rows = rows p.Rows = rows
p.PollURL = outboundLog + "/fragment" + logQuery(p.domain, p.app, page) p.PollURL = outboundLog + "/fragment" + p.filter.query(page)
if len(rows) == 0 { if len(rows) == 0 {
return p return p
} }
text := Rich("Page " + strconv.Itoa(page) + " of " + strconv.Itoa(last)) text := Rich("Page " + strconv.Itoa(page) + " of " + strconv.Itoa(last))
if page > 1 { if page > 1 {
text = append(text, Inline{Text: " · "}, Link(LogHref(p.domain, p.app, page-1), "← Newer")) text = append(text, Inline{Text: " · "}, Link(LogHref(p.filter, page-1), "← Newer"))
} }
p.Foot.Text = text p.Foot.Text = text
if page < last { if page < last {
p.Foot.Link = &Anchor{Href: LogHref(p.domain, p.app, page+1), Label: "Older →"} p.Foot.Link = &Anchor{Href: LogHref(p.filter, page+1), Label: "Older →"}
} }
return p return p
} }
+21 -12
View File
@@ -29,32 +29,41 @@ func TestOutLogShowsTheRowsAndTheirFilters(t *testing.T) {
pageLacks(t, "Outbound log", out, `← Newer`) pageLacks(t, "Outbound log", out, `← Newer`)
// Without the global role there is nowhere to change the retention. // Without the global role there is nowhere to change the retention.
p := NewOutLog(Meta{User: "ops", HasOutbound: true}, 7, false, []string{"example.org"}, nil, "", "") p := NewOutLog(Meta{User: "ops", HasOutbound: true}, 7, false, []string{"example.org"}, nil, OutLogFilter{})
out = renderSignedIn(t, "out-log", p.WithRows(nil, 1, 1)) out = renderSignedIn(t, "out-log", p.WithRows(nil, 1, 1))
pageHas(t, "Outbound log", out, `Rows older than 7 days are deleted.`) pageHas(t, "Outbound log", out, `Rows older than 7 days are deleted.`)
pageLacks(t, "Outbound log", out, `/server/settings`) pageLacks(t, "Outbound log", out, `/server/settings`)
pageLacks(t, "Outbound log", renderSignedIn(t, "out-log", NewOutLog(Meta{User: "ops", HasOutbound: true}, 1, false, nil, nil, "", "").WithRows(nil, 1, 1)), pageLacks(t, "Outbound log", renderSignedIn(t, "out-log", NewOutLog(Meta{User: "ops", HasOutbound: true}, 1, false, nil, nil, OutLogFilter{}).WithRows(nil, 1, 1)),
`older than 1 days`) `older than 1 days`)
} }
// The filter in force is the one selected, and the poll, the paging and the // The filter in force is the one selected, and the poll, the paging and the
// links of the rows all carry it — and say nothing for a filter that is not set. // links of the rows all carry it — and say nothing for a filter that is not set.
func TestOutLogCarriesItsFilters(t *testing.T) { func TestOutLogCarriesItsFilters(t *testing.T) {
p := NewOutLog(admin(), 30, true, []string{"example.org", "shop.example.org"}, []string{"alerts"}, "shop.example.org", "alerts") p := NewOutLog(admin(), 30, true, []string{"example.org", "shop.example.org"}, []string{"alerts"}, OutLogFilter{Domain: "shop.example.org", App: "alerts", Status: "rejected", From: "2026-09-14", To: "2026-09-21"})
p.WithRows([]OutLogRow{{Time: "09-21 14:01:52", From: "a@example.org", To: "b@example.org", Subject: "Hi", p.WithRows([]OutLogRow{{Time: "09-21 14:01:52", From: "a@example.org", To: "b@example.org", Subject: "Hi",
Status: Tag{Status: "sent"}, Href: p.DetailHref(7, 3)}}, 3, 5) Status: Tag{Status: "sent"}, Href: p.DetailHref(7, 3)}}, 3, 5)
out := renderSignedIn(t, "out-log", p) out := renderSignedIn(t, "out-log", p)
// The keys in order (url.Values.Encode sorts them), the page among them.
q := func(page string) string {
return `app=alerts&amp;domain=shop.example.org&amp;from=2026-09-14&amp;p=` + page + `&amp;status=rejected&amp;to=2026-09-21`
}
pageHas(t, "Outbound log", out, pageHas(t, "Outbound log", out,
`<option value="shop.example.org" selected>shop.example.org</option>`, `<option value="alerts" selected>alerts</option>`, `<option value="shop.example.org" selected>shop.example.org</option>`, `<option value="alerts" selected>alerts</option>`,
`hx-get="/outbound/log/fragment?app=alerts&amp;domain=shop.example.org&amp;p=3"`, `<option value="rejected" selected>rejected</option>`, `<option value="queued">queued</option>`,
`href="/outbound/log/7?app=alerts&amp;domain=shop.example.org&amp;p=3"`, `name="from" value="2026-09-14"`, `name="to" value="2026-09-21"`,
`Page 3 of 5 · <a href="/outbound/log?app=alerts&amp;domain=shop.example.org&amp;p=2">← Newer</a>`, `hx-get="/outbound/log/fragment?`+q("3")+`"`,
`<a href="/outbound/log?app=alerts&amp;domain=shop.example.org&amp;p=4">Older →</a>`, `href="/outbound/log/7?`+q("3")+`"`,
`Page 3 of 5 · <a href="/outbound/log?`+q("2")+`">← Newer</a>`,
`<a href="/outbound/log?`+q("4")+`">Older →</a>`,
) )
if got := LogHref("", "", 1); got != "/outbound/log" { // The status list is the statuses of a row, rejected the three refusals
// together; a reason is not a status (owner, 2026-10-13).
pageLacks(t, "Outbound log", out, `<option value="rate-limited"`)
if got := LogHref(OutLogFilter{}, 1); got != "/outbound/log" {
t.Errorf("the first unfiltered page is %q", got) t.Errorf("the first unfiltered page is %q", got)
} }
if got := LogHref("a b&c", "", 2); got != "/outbound/log?domain=a+b%26c&p=2" { if got := LogHref(OutLogFilter{Domain: "a b&c"}, 2); got != "/outbound/log?domain=a+b%26c&p=2" {
t.Errorf("a filter is not encoded: %q", got) t.Errorf("a filter is not encoded: %q", got)
} }
} }
@@ -62,12 +71,12 @@ func TestOutLogCarriesItsFilters(t *testing.T) {
// The last page has nothing older, and a page with no rows says so and still // The last page has nothing older, and a page with no rows says so and still
// polls: a log that is empty now is the one that fills while the page is open. // polls: a log that is empty now is the one that fills while the page is open.
func TestOutLogLastPageAndEmptyPage(t *testing.T) { func TestOutLogLastPageAndEmptyPage(t *testing.T) {
p := NewOutLog(admin(), 30, true, nil, nil, "", "") p := NewOutLog(admin(), 30, true, nil, nil, OutLogFilter{})
out := renderSignedIn(t, "out-log", p.WithRows([]OutLogRow{{Subject: "x", Status: Tag{Status: "sent"}, Href: "/outbound/log/1"}}, 2, 2)) out := renderSignedIn(t, "out-log", p.WithRows([]OutLogRow{{Subject: "x", Status: Tag{Status: "sent"}, Href: "/outbound/log/1"}}, 2, 2))
pageHas(t, "Outbound log", out, `Page 2 of 2`, `← Newer</a>`) pageHas(t, "Outbound log", out, `Page 2 of 2`, `← Newer</a>`)
pageLacks(t, "Outbound log", out, `Older →`) pageLacks(t, "Outbound log", out, `Older →`)
out = renderSignedIn(t, "out-log", NewOutLog(admin(), 30, true, nil, nil, "", "").WithRows(nil, 1, 1)) out = renderSignedIn(t, "out-log", NewOutLog(admin(), 30, true, nil, nil, OutLogFilter{}).WithRows(nil, 1, 1))
pageHas(t, "Outbound log", out, `No messages logged yet.`, `id="out-log-rows"`, `data-poll`, `id="out-log-foot"`, `New rows appear on their own`) pageHas(t, "Outbound log", out, `No messages logged yet.`, `id="out-log-rows"`, `data-poll`, `id="out-log-foot"`, `New rows appear on their own`)
pageLacks(t, "Outbound log", out, `<table`, `Page 1`) pageLacks(t, "Outbound log", out, `<table`, `Page 1`)
} }
@@ -87,7 +96,7 @@ func TestOutLogFragmentMarksOnlyTheFoot(t *testing.T) {
} }
func TestOutLogEscapesWhatSendersWrote(t *testing.T) { func TestOutLogEscapesWhatSendersWrote(t *testing.T) {
p := NewOutLog(admin(), 30, true, []string{`<b>d</b>`}, []string{`"><i>`}, "", "") p := NewOutLog(admin(), 30, true, []string{`<b>d</b>`}, []string{`"><i>`}, OutLogFilter{})
p.WithRows([]OutLogRow{{Time: "t", From: `"><img src=x>@a`, To: `<u>@b`, Subject: `<script>alert(1)</script>`, p.WithRows([]OutLogRow{{Time: "t", From: `"><img src=x>@a`, To: `<u>@b`, Subject: `<script>alert(1)</script>`,
Status: Tag{Status: `<i>`}, Href: "/outbound/log/1"}}, 1, 1) Status: Tag{Status: `<i>`}, Href: "/outbound/log/1"}}, 1, 1)
out := renderSignedIn(t, "out-log", p) out := renderSignedIn(t, "out-log", p)