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, `