diff --git a/CHANGELOG.md b/CHANGELOG.md index bfde786..37b9023 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ## [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 - **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 leaves the page; when a field holds an edit not saved yet, it now asks 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 diff --git a/docs/audit-2026-10-12.md b/docs/audit-2026-10-12.md index 7e5c6e9..994b51f 100644 --- a/docs/audit-2026-10-12.md +++ b/docs/audit-2026-10-12.md @@ -1237,9 +1237,14 @@ command whose leftover is the owner's alone. `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 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** -- [ ] **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 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 @@ -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 day): the same control, drawn in the mockup first. Both filters survive 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). `health.CheckSocket` ([socket.go](../internal/health/socket.go)) stats the 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 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** + *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** (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 diff --git a/docs/guide.md b/docs/guide.md index 1693602..4579351 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -1389,9 +1389,12 @@ 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 and application; a user with the *domain* role sees only the -rows of their domains. It refreshes its rows without resetting a filter being -chosen. A row identifies its message and nothing more — time, +filters by domain, application, status and day (from and to, UTC days, 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, +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 delivered), `sent` (handed off successfully), `deferred` (Postfix is retrying), `bounced` (final failure: refused for good by the receiving server, or given diff --git a/docs/roadmap.md b/docs/roadmap.md index 033c124..9b9fc4a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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) | | 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) | -| 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 development convenience): **panel-redesign** shipped as `2.0.0` (2026-10-11) and diff --git a/internal/domain/delete_test.go b/internal/domain/delete_test.go new file mode 100644 index 0000000..f37ebad --- /dev/null +++ b/internal/domain/delete_test.go @@ -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) + } +} diff --git a/internal/domain/service.go b/internal/domain/service.go index ca85035..14e2f08 100644 --- a/internal/domain/service.go +++ b/internal/domain/service.go @@ -1,6 +1,7 @@ package domain import ( + "errors" "fmt" "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 { 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 { - return err + tablesRebuilt = false + errs = append(errs, fmt.Errorf("rebuild OpenDKIM's tables: %w", err)) } 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 // 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 - // with a new record to publish, which Settings shows - if it was not. - // Either way the directory holds the key now in use. - if d.Name == s.serverName { - return nil + // Either way the directory holds the key now in use. And while the tables + // may still name the deleted domain's key, the key stays. + 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 { - // The domain is gone from the registry and tables; a leftover key - // directory is harmless. Surface it so it is not silently ignored. - return fmt.Errorf("domain deleted but key cleanup failed: %w", err) + if len(errs) > 0 { + return fmt.Errorf("%w (%s): %w", ErrDeletedIncomplete, d.Name, errors.Join(errs...)) } 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). func (s *Service) DKIMRecord(d store.Domain) (DKIMRecord, error) { return s.odk.Record(d.Name, d.DKIMSelector) diff --git a/internal/store/queryplan_test.go b/internal/store/queryplan_test.go index cfc6a44..9ca50ec 100644 --- a/internal/store/queryplan_test.go +++ b/internal/store/queryplan_test.go @@ -3,6 +3,7 @@ package store import ( "strings" "testing" + "time" ) // 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) + } +} diff --git a/internal/store/sendlog.go b/internal/store/sendlog.go index d76e903..2fdb4d8 100644 --- a/internal/store/sendlog.go +++ b/internal/store/sendlog.go @@ -430,6 +430,11 @@ type SendLogFilter struct { AppLogin string 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. + // Zero values do not narrow. + Status string + Since, Until time.Time } // 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 = ?") 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 { return "", nil } diff --git a/internal/web/handlers/handlers_dmarc_test.go b/internal/web/handlers/handlers_dmarc_test.go index 6ee0df7..12d0b82 100644 --- a/internal/web/handlers/handlers_dmarc_test.go +++ b/internal/web/handlers/handlers_dmarc_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "net/url" "regexp" + "strconv" "strings" "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") } + +// 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) + } +} diff --git a/internal/web/handlers/handlers_domains.go b/internal/web/handlers/handlers_domains.go index f88b14a..b62f0df 100644 --- a/internal/web/handlers/handlers_domains.go +++ b/internal/web/handlers/handlers_domains.go @@ -8,6 +8,7 @@ import ( "sync" "time" + "github.com/mixeme/selfpost/internal/domain" "github.com/mixeme/selfpost/internal/health" "github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/web/validate" @@ -147,7 +148,9 @@ func (h *Handlers) HandleDeleteDomain(w http.ResponseWriter, r *http.Request) { if !ok { return } + name := "" if d, err := h.domains.Get(id); err == nil { + name = d.Name defer h.dns.Forget(d.Name) } if err := h.domains.Delete(id); err != nil { @@ -156,6 +159,11 @@ func (h *Handlers) HandleDeleteDomain(w http.ResponseWriter, r *http.Request) { return } 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) return } diff --git a/internal/web/handlers/handlers_monitor.go b/internal/web/handlers/handlers_monitor.go index e968f4a..a533c72 100644 --- a/internal/web/handlers/handlers_monitor.go +++ b/internal/web/handlers/handlers_monitor.go @@ -6,6 +6,7 @@ import ( "io/fs" "net/http" "net/url" + "slices" "sort" "strconv" "time" @@ -301,7 +302,7 @@ func logLines(lines []string) []view.LogLine { func deliveriesBackURL(r *http.Request) string { q := r.URL.Query() 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 != "" { back.Set(k, v) } @@ -364,6 +365,7 @@ func (h *Handlers) outLogPage(r *http.Request) (*view.OutLog, error) { filter := store.SendLogFilter{ Domain: q.Get("domain"), AppLogin: q.Get("app"), + Status: outLogStatus(q.Get("status")), // A global administrator reads the whole journal, including rows left // 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 @@ -379,6 +381,15 @@ func (h *Handlers) outLogPage(r *http.Request) (*view.OutLog, error) { 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) if err != nil { @@ -396,7 +407,7 @@ func (h *Handlers) outLogPage(r *http.Request) (*view.OutLog, error) { if err != nil { 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)) for i, row := range rows { 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 } +// 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 // to 1 for anything missing or malformed rather than rejecting the request. // diff --git a/internal/web/handlers/handlers_outbound_log_test.go b/internal/web/handlers/handlers_outbound_log_test.go index 7055b09..1426522 100644 --- a/internal/web/handlers/handlers_outbound_log_test.go +++ b/internal/web/handlers/handlers_outbound_log_test.go @@ -3,8 +3,10 @@ package handlers import ( "fmt" "net/http" + "net/http/httptest" "strings" "testing" + "time" "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, ``) + if got := strings.Count(rejected, `">Details`); 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`); 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`); 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`); 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`) { + 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+`&status=rejected"`, + `?from=`+today+`&status=rejected">Details`) + frag := getBody(t, h.HandleDeliveriesRows, "/outbound/log/fragment?status=rejected&from="+today) + if got := strings.Count(frag, `">Details`); 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) + } +} diff --git a/internal/web/view/fixtures_test.go b/internal/web/view/fixtures_test.go index 71ae1c8..8b715d4 100644 --- a/internal/web/view/fixtures_test.go +++ b/internal/web/view/fixtures_test.go @@ -252,7 +252,7 @@ func outDomainDeleteFixture() *OutDomainDelete { // the first of 412 pages, one deferred, one bounced, one refused by a rate limit. func outLogFixture() *OutLog { 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. row := func(id int64, at, from, to, subject, status string) OutLogRow { status, reason, _ := strings.Cut(status, ":") diff --git a/internal/web/view/outbound_log.go b/internal/web/view/outbound_log.go index d869a8b..aa652ab 100644 --- a/internal/web/view/outbound_log.go +++ b/internal/web/view/outbound_log.go @@ -20,16 +20,32 @@ import ( 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 // path. -func logQuery(domain, app string, page int) string { +func (f OutLogFilter) query(page int) string { q := url.Values{} - if domain != "" { - q.Set("domain", domain) - } - if app != "" { - q.Set("app", app) + for name, v := range map[string]string{"domain": f.Domain, "app": f.App, "status": f.Status, "from": f.From, "to": f.To} { + if v != "" { + q.Set(name, v) + } } if page > 1 { 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. -func LogHref(domain, app string, page int) string { - return outboundLog + logQuery(domain, app, page) +func LogHref(f OutLogFilter, page int) string { + return outboundLog + f.query(page) } // 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 Foot Foot - domain, app string + filter OutLogFilter } // 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 -// the two filters (only what the viewer reaches), domain and app the ones in -// force. retentionDays is how long rows are kept; canSettings, the global role, +// those two filters (only what the viewer reaches), f the filters in force. retentionDays is how long rows are kept; canSettings, the global role, // 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" lead := Rich("Every message an application handed to the relay. Rows older than " + plural(int64(retentionDays), "day", "days") + " are deleted") 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{ Action: outboundLog, Selects: []Select{ - {Name: "domain", Label: "Domain", Options: options("All domains", domains), Selected: domain}, - {Name: "app", Label: "Application", Options: options("All applications", apps), Selected: app}, + {Name: "domain", Label: "Domain", Options: options("All domains", domains), Selected: f.Domain}, + {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.")}, - 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")}, - domain: domain, app: app, + filter: f, } } // 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. 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 @@ -130,17 +150,17 @@ func (p *OutLog) DetailHref(id int64, page int) string { // page again. func (p *OutLog) WithRows(rows []OutLogRow, page, last int) *OutLog { 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 { return p } text := Rich("Page " + strconv.Itoa(page) + " of " + strconv.Itoa(last)) 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 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 } diff --git a/internal/web/view/outbound_log_test.go b/internal/web/view/outbound_log_test.go index edaef6a..ed681d7 100644 --- a/internal/web/view/outbound_log_test.go +++ b/internal/web/view/outbound_log_test.go @@ -29,32 +29,41 @@ func TestOutLogShowsTheRowsAndTheirFilters(t *testing.T) { pageLacks(t, "Outbound log", out, `← Newer`) // 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)) pageHas(t, "Outbound log", out, `Rows older than 7 days are deleted.`) 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`) } // 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. 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", Status: Tag{Status: "sent"}, Href: p.DetailHref(7, 3)}}, 3, 5) 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&domain=shop.example.org&from=2026-09-14&p=` + page + `&status=rejected&to=2026-09-21` + } pageHas(t, "Outbound log", out, ``, ``, - `hx-get="/outbound/log/fragment?app=alerts&domain=shop.example.org&p=3"`, - `href="/outbound/log/7?app=alerts&domain=shop.example.org&p=3"`, - `Page 3 of 5 · ← Newer`, - `Older →`, + ``, ``, + `name="from" value="2026-09-14"`, `name="to" value="2026-09-21"`, + `hx-get="/outbound/log/fragment?`+q("3")+`"`, + `href="/outbound/log/7?`+q("3")+`"`, + `Page 3 of 5 · ← Newer`, + `Older →`, ) - 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, `