inbound-antispam-panel: Inbound > Log and Inbound > Filter lists
Step 6/12 of docs/plans/inbound-antispam-panel.md. Written by a Sonnet
subagent from the mockups, reviewed and corrected by me.
GET /inbound/log - the journal a page at a time (50), filtered by
domain, decision and day; QueryInboundSpam / CountInboundSpam with the
viewer's inbound domains as the scope. The global role reads all of it
and gets "Deny sender" / "Allow sender" links into the lists; a user
with the domain role reads the rows of the inbound domains assigned to
them, without the links. The plan said global-only for the journal;
the accepted mockup and 2.0's delegation of Inbound say otherwise -
recorded in the plan as built.
GET, POST /inbound/filter-lists and POST .../{id}/delete - global role,
404 otherwise. Add form (prefilled from the query), the two lists with
Remove as the kit's row action, and "Where the lists go" with the
filter's state and when the maps were last written
(Lists.LastWritten). A refused or duplicate entry is 422 with the
message and what was typed.
Server > Settings: "Keep inbound log rows, days" with the relay on.
Help: Inbound log, Filter lists. Navigation: Log for everyone who sees
Inbound, Filter lists for the global role (Shell.ShowFilterLists).
Status tags accept / reject / tempfail join the kit's mapping.
Tests: page fixtures for both screens, held to outlines.json; view and
handler tests (both roles, filters, paging, relay off, add / duplicate
/ invalid / delete, map file content, settings field, help). e2e reads
the journal from the page instead of the database and adds and removes
a deny entry through the panel, reading deny_domain.map after each.
Seen: both pages and Settings rendered from their fixtures and compared
with the mockups side by side (headless Edge, 1360 px) - same layout
and wording.
Checked on the test server, uncommitted tree: go vet ./... ok,
go test ./... ok (22 packages), make e2e ok.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+13
-3
@@ -23,7 +23,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
of a message that was accepted for an inbound domain: client address,
|
of a message that was accepted for an inbound domain: client address,
|
||||||
envelope, subject and - with a spam filter attached - the filter's action,
|
envelope, subject and - with a spam filter attached - the filter's action,
|
||||||
score and heaviest symbols. It only watches: it refuses nothing and Postfix
|
score and heaviest symbols. It only watches: it refuses nothing and Postfix
|
||||||
accepts mail when it is down. There is no page for the journal yet.
|
accepts mail when it is down.
|
||||||
- **Refused inbound mail is recorded too**, from the mail log: a recipient
|
- **Refused inbound mail is recorded too**, from the mail log: a recipient
|
||||||
Postfix refused at an inbound domain (unknown user, sender domain not
|
Postfix refused at an inbound domain (unknown user, sender domain not
|
||||||
found), and a message the spam filter rejected, deferred, held or
|
found), and a message the spam filter rejected, deferred, held or
|
||||||
@@ -42,8 +42,18 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
They are written as six files under `data/antispam/`, which the fragment
|
They are written as six files under `data/antispam/`, which the fragment
|
||||||
mounts into the sidecar together with `antispam/rspamd/multimap.conf`;
|
mounts into the sidecar together with `antispam/rspamd/multimap.conf`;
|
||||||
rspamd rereads a changed file by itself. Deny wins over allow. The files
|
rspamd rereads a changed file by itself. Deny wins over allow. The files
|
||||||
are there from the first start with the inbound relay on; the page that
|
are there from the first start with the inbound relay on.
|
||||||
edits the lists comes next.
|
- **Inbound › Log**: the journal as a page - time, sender and client address,
|
||||||
|
recipient, subject, decision and why, filtered by domain, decision and day.
|
||||||
|
A user with the *domain* role sees the rows of the inbound domains assigned
|
||||||
|
to them. For the global role each row links to the filter lists with the
|
||||||
|
sender filled in (*Deny sender* / *Allow sender*).
|
||||||
|
- **Inbound › Filter lists** (global role): add and remove allow and deny
|
||||||
|
entries; the page says where the lists go and whether the filter is on to
|
||||||
|
read them.
|
||||||
|
- **Server › Settings**: *Keep inbound log rows, days*, shown with the inbound
|
||||||
|
relay on.
|
||||||
|
- Help: *Inbound log* and *Filter lists*.
|
||||||
- New setting `ANTISPAM_DIR` (default `/data/antispam`).
|
- New setting `ANTISPAM_DIR` (default `/data/antispam`).
|
||||||
- New setting `INBOUND_JOURNAL_MILTER_SOCKET` (default
|
- New setting `INBOUND_JOURNAL_MILTER_SOCKET` (default
|
||||||
`/run/selfpost/inbound-journal.sock`).
|
`/run/selfpost/inbound-journal.sock`).
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ func newPanel(cfg config, st *store.Store) (*web.Server, error) {
|
|||||||
// effective config, including a manual override; the panel keeps this
|
// effective config, including a manual override; the panel keeps this
|
||||||
// snapshot for the process lifetime (architecture.md).
|
// snapshot for the process lifetime (architecture.md).
|
||||||
retryPolicy := postfix.LoadRetryPolicy()
|
retryPolicy := postfix.LoadRetryPolicy()
|
||||||
return web.New(st, ms.Domains, ms.Apps, ms.Inbound, ms.DMARC, web.Config{
|
return web.New(st, ms.Domains, ms.Apps, ms.Inbound, ms.Lists, ms.DMARC, web.Config{
|
||||||
Hostname: cfg.hostname,
|
Hostname: cfg.hostname,
|
||||||
CookieSecure: cfg.cookieSecure,
|
CookieSecure: cfg.cookieSecure,
|
||||||
SubmissionEnabled: cfg.submissionEnabled,
|
SubmissionEnabled: cfg.submissionEnabled,
|
||||||
@@ -121,6 +121,8 @@ func newPanel(cfg config, st *store.Store) (*web.Server, error) {
|
|||||||
InboundAntispamAction: cfg.inboundAntispamAction,
|
InboundAntispamAction: cfg.inboundAntispamAction,
|
||||||
DMARCEnabled: cfg.dmarcEnabled,
|
DMARCEnabled: cfg.dmarcEnabled,
|
||||||
SendLogRetentionEnvDefault: cfg.retentionDays,
|
SendLogRetentionEnvDefault: cfg.retentionDays,
|
||||||
|
|
||||||
|
InboundSpamLogRetentionEnvDefault: cfg.inboundSpamLogRetentionDays,
|
||||||
}, cfg.setupTokenPath)
|
}, cfg.setupTokenPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -864,6 +864,13 @@ spam score outweighs, so it is accepted - unless a deny entry matches the same
|
|||||||
message: **deny wins**, whichever entries they are. The files are readable by
|
message: **deny wins**, whichever entries they are. The files are readable by
|
||||||
anyone on the host; they hold addresses, not secrets.
|
anyone on the host; they hold addresses, not secrets.
|
||||||
|
|
||||||
|
In the panel the lists are **Inbound › Filter lists** (global role), and what
|
||||||
|
port 25 decided about each message is **Inbound › Log**: accepted mail with the
|
||||||
|
filter's verdict, refused mail with the reply it was given. A row of the log
|
||||||
|
leads to the lists with its sender filled in. A user with the *domain* role
|
||||||
|
sees the log of the inbound domains assigned to them, and not the lists. How
|
||||||
|
long the log keeps a row is set under **Server › Settings**.
|
||||||
|
|
||||||
The panel only reports this setting; it does not change it. Every inbound
|
The panel only reports this setting; it does not change it. Every inbound
|
||||||
domain page has a **Spam filter** box — on or off, what happens to mail while
|
domain page has a **Spam filter** box — on or off, what happens to mail while
|
||||||
the filter is down, and that the filter belongs to the server, not to the
|
the filter is down, and that the filter belongs to the server, not to the
|
||||||
|
|||||||
@@ -84,6 +84,13 @@ configured.
|
|||||||
**Global administrator only** for journal and lists — same as inbound relay. No
|
**Global administrator only** for journal and lists — same as inbound relay. No
|
||||||
domain-admin access in v1.
|
domain-admin access in v1.
|
||||||
|
|
||||||
|
*As built (2026-10-11):* the lists are the global role's; the **log follows
|
||||||
|
the delegation of Inbound** that 2.0 introduced after this was written - a
|
||||||
|
user with the `domain` role sees the rows of the inbound domains assigned to
|
||||||
|
them and nothing else, without the *Allow / Deny sender* links. That is what
|
||||||
|
the accepted `in-log` mockup draws (no "global role" in its head). The owner
|
||||||
|
was told and can turn it back into a global-only page.
|
||||||
|
|
||||||
### Retention
|
### Retention
|
||||||
|
|
||||||
Mirror send-log retention:
|
Mirror send-log retention:
|
||||||
@@ -248,7 +255,7 @@ Target version cut: **`2.1.0`** (MINOR), after `2.0.0`. One commit per step;
|
|||||||
- [x] Inbound journal-milter + Postfix wiring — **Composer**
|
- [x] Inbound journal-milter + Postfix wiring — **Composer**
|
||||||
- [x] mail.log tailer: inbound reject rows — **Composer**
|
- [x] mail.log tailer: inbound reject rows — **Composer**
|
||||||
- [x] List CRUD + validation + atomic rspamd map sync — **Composer**
|
- [x] List CRUD + validation + atomic rspamd map sync — **Composer**
|
||||||
- [ ] Panel: Inbound › Log and Inbound › Filter lists from the kit partials, mockups `in-log` / `in-filter-lists` (add client IP and engine columns mockup-first), evidence pairs — **Composer**
|
- [x] Panel: Inbound › Log and Inbound › Filter lists from the kit partials, mockups `in-log` / `in-filter-lists` (add client IP and engine columns mockup-first), evidence pairs — **Composer**
|
||||||
- [ ] Backup includes `/data/antispam/` — **Composer**
|
- [ ] Backup includes `/data/antispam/` — **Composer**
|
||||||
- [ ] Unit + handler tests — **Composer**
|
- [ ] Unit + handler tests — **Composer**
|
||||||
- [ ] [guide.md](../guide.md), [architecture.md](../architecture.md) — **Composer**
|
- [ ] [guide.md](../guide.md), [architecture.md](../architecture.md) — **Composer**
|
||||||
|
|||||||
+1
-1
@@ -30,7 +30,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
|
|||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| preflight | Installation check page (`/preflight`) | candidate | — | [plans/preflight.md](plans/preflight.md) |
|
| preflight | Installation check page (`/preflight`) | candidate | — | [plans/preflight.md](plans/preflight.md) |
|
||||||
| contributing | `CONTRIBUTING.md` | candidate | — | — |
|
| contributing | `CONTRIBUTING.md` | candidate | — | — |
|
||||||
| inbound-antispam-panel | Inbound antispam journal + allow/deny lists | in progress | 5/12 | [plans/inbound-antispam-panel.md](plans/inbound-antispam-panel.md) |
|
| inbound-antispam-panel | Inbound antispam journal + allow/deny lists | in progress | 6/12 | [plans/inbound-antispam-panel.md](plans/inbound-antispam-panel.md) |
|
||||||
| inbound-quarantine | Inbound spam quarantine (hold / review / release) | candidate | — | [plans/inbound-quarantine.md](plans/inbound-quarantine.md) |
|
| inbound-quarantine | Inbound spam quarantine (hold / review / release) | candidate | — | [plans/inbound-quarantine.md](plans/inbound-quarantine.md) |
|
||||||
| csrf-tokens | Per-session CSRF tokens on state-changing forms | candidate | — | — |
|
| csrf-tokens | Per-session CSRF tokens on state-changing forms | candidate | — | — |
|
||||||
| template-data-typing | Typed structs for template data instead of `map[string]any` | candidate | — | — |
|
| template-data-typing | Typed structs for template data instead of `map[string]any` | candidate | — | — |
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/mixeme/selfpost/internal/atomicfile"
|
"github.com/mixeme/selfpost/internal/atomicfile"
|
||||||
@@ -140,6 +141,19 @@ func (l *Lists) Sync() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LastWritten is when the maps were last written: the newest modification
|
||||||
|
// time of the six files, the zero time while none exists (the filter lists
|
||||||
|
// have never been touched, or the directory is gone).
|
||||||
|
func (l *Lists) LastWritten() time.Time {
|
||||||
|
var newest time.Time
|
||||||
|
for _, name := range mapFiles {
|
||||||
|
if fi, err := os.Stat(filepath.Join(l.dir, name)); err == nil && fi.ModTime().After(newest) {
|
||||||
|
newest = fi.ModTime()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newest
|
||||||
|
}
|
||||||
|
|
||||||
// normalizeListValue validates one entry and returns the form it is stored and
|
// normalizeListValue validates one entry and returns the form it is stored and
|
||||||
// written in: lower case for an address or a domain, the canonical text of an
|
// written in: lower case for an address or a domain, the canonical text of an
|
||||||
// address or a network for an IP.
|
// address or a network for an IP.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/mixeme/selfpost/internal/store"
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
)
|
)
|
||||||
@@ -235,3 +236,43 @@ func TestListsSyncWritesEmptyMaps(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListsLastWritten(t *testing.T) {
|
||||||
|
l, _, dir := testLists(t)
|
||||||
|
if got := l.LastWritten(); !got.IsZero() {
|
||||||
|
t.Fatalf("LastWritten before anything was written = %v", got)
|
||||||
|
}
|
||||||
|
if err := l.Sync(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := l.LastWritten(); got.IsZero() || time.Since(got) > time.Minute {
|
||||||
|
t.Errorf("LastWritten after a sync = %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The newest of the six counts, whichever it is.
|
||||||
|
old := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||||
|
for _, name := range mapFiles {
|
||||||
|
if err := os.Chtimes(filepath.Join(dir, name), old, old); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newest := old.Add(48 * time.Hour)
|
||||||
|
if err := os.Chtimes(filepath.Join(dir, "deny_domain.map"), newest, newest); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := l.LastWritten(); !got.Equal(newest) {
|
||||||
|
t.Errorf("LastWritten = %v, want %v", got, newest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A file that is not one of the six does not count.
|
||||||
|
other := newest.Add(time.Hour)
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "notes.txt"), nil, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Chtimes(filepath.Join(dir, "notes.txt"), other, other); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := l.LastWritten(); !got.Equal(newest) {
|
||||||
|
t.Errorf("LastWritten counts a stray file: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -81,6 +81,132 @@ func (s *Store) PruneInboundSpam(cutoff time.Time, keep int) (int64, error) {
|
|||||||
return n + m, nil
|
return n + m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InboundSpamRow is one row of the inbound antispam journal as it is read
|
||||||
|
// back for the panel: the entry as it was written, with its id and when it
|
||||||
|
// happened.
|
||||||
|
type InboundSpamRow struct {
|
||||||
|
ID int64
|
||||||
|
InboundDomain string
|
||||||
|
ClientIP string
|
||||||
|
From string
|
||||||
|
To string
|
||||||
|
Subject string
|
||||||
|
Decision string
|
||||||
|
Engine string
|
||||||
|
Detail string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// InboundSpamFilter narrows QueryInboundSpam and CountInboundSpam. Like
|
||||||
|
// SendLogFilter it carries two kinds of narrowing that behave in opposite ways
|
||||||
|
// on purpose.
|
||||||
|
//
|
||||||
|
// Domain, Decision, Since and Until are the operator's own filters, chosen in
|
||||||
|
// the UI: an empty field matches everything. Since and Until are days, both
|
||||||
|
// inclusive and read as UTC days whatever the time of day they carry.
|
||||||
|
//
|
||||||
|
// Domains is the authorization scope, which no query parameter may widen: the
|
||||||
|
// exhaustive set of inbound domain names the caller may read, applied as an IN
|
||||||
|
// constraint. nil lifts it (the global role reads the whole journal, including
|
||||||
|
// rows of a domain deleted since); an empty set is a caller who reaches
|
||||||
|
// nothing and gets nothing. Unlike SendLogFilter, forgetting the scope is
|
||||||
|
// therefore the permissive case - the caller states "all" by leaving it out -
|
||||||
|
// so every caller that serves someone other than the global role must set it.
|
||||||
|
type InboundSpamFilter struct {
|
||||||
|
Domain string
|
||||||
|
Decision string
|
||||||
|
Since time.Time
|
||||||
|
Until time.Time
|
||||||
|
Domains []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryInboundSpam returns journal rows matching filter, newest first, for
|
||||||
|
// the Inbound log's server-side pagination. A refusal is dated by the mail.log
|
||||||
|
// line the tailer read it from, which can be older than rows written since, so
|
||||||
|
// the order is by time and not by id.
|
||||||
|
func (s *Store) QueryInboundSpam(filter InboundSpamFilter, limit, offset int) ([]InboundSpamRow, error) {
|
||||||
|
where, args := inboundSpamWhere(filter)
|
||||||
|
args = append(args, limit, offset)
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
`SELECT id, inbound_domain, client_ip, from_addr, to_addr, subject, decision, engine, detail, created_at
|
||||||
|
FROM inbound_spam_log`+where+`
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
LIMIT ? OFFSET ?`,
|
||||||
|
args...,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query inbound_spam_log: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []InboundSpamRow
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
row InboundSpamRow
|
||||||
|
created string
|
||||||
|
)
|
||||||
|
if err := rows.Scan(&row.ID, &row.InboundDomain, &row.ClientIP, &row.From, &row.To,
|
||||||
|
&row.Subject, &row.Decision, &row.Engine, &row.Detail, &created); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan inbound_spam_log row: %w", err)
|
||||||
|
}
|
||||||
|
row.CreatedAt, _ = time.Parse(time.RFC3339, created)
|
||||||
|
out = append(out, row)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountInboundSpam returns how many journal rows match filter, for the page
|
||||||
|
// numbers under the Inbound log.
|
||||||
|
func (s *Store) CountInboundSpam(filter InboundSpamFilter) (int64, error) {
|
||||||
|
where, args := inboundSpamWhere(filter)
|
||||||
|
var n int64
|
||||||
|
if err := s.db.QueryRow(`SELECT COUNT(*) FROM inbound_spam_log`+where, args...).Scan(&n); err != nil {
|
||||||
|
return 0, fmt.Errorf("count inbound_spam_log: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func inboundSpamWhere(f InboundSpamFilter) (string, []any) {
|
||||||
|
var clauses []string
|
||||||
|
var args []any
|
||||||
|
if f.Domains != nil {
|
||||||
|
if len(f.Domains) == 0 {
|
||||||
|
return " WHERE 1 = 0", nil
|
||||||
|
}
|
||||||
|
marks := make([]string, len(f.Domains))
|
||||||
|
for i, name := range f.Domains {
|
||||||
|
marks[i] = "?"
|
||||||
|
args = append(args, name)
|
||||||
|
}
|
||||||
|
clauses = append(clauses, "inbound_domain IN ("+strings.Join(marks, ", ")+")")
|
||||||
|
}
|
||||||
|
if f.Domain != "" {
|
||||||
|
clauses = append(clauses, "inbound_domain = ?")
|
||||||
|
args = append(args, f.Domain)
|
||||||
|
}
|
||||||
|
if f.Decision != "" {
|
||||||
|
clauses = append(clauses, "decision = ?")
|
||||||
|
args = append(args, f.Decision)
|
||||||
|
}
|
||||||
|
// created_at is RFC3339 UTC, so comparing it as text against the same
|
||||||
|
// format is comparing moments: from midnight of the first day up to, not
|
||||||
|
// including, midnight after the last.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
return " WHERE " + strings.Join(clauses, " AND "), args
|
||||||
|
}
|
||||||
|
|
||||||
// IsInboundDomain reports whether name is a configured inbound domain. Port 25
|
// IsInboundDomain reports whether name is a configured inbound domain. Port 25
|
||||||
// also takes mail that is not the relay's - DMARC reports for the server's own
|
// also takes mail that is not the relay's - DMARC reports for the server's own
|
||||||
// name - and the journal keeps rows for inbound domains only.
|
// name - and the journal keeps rows for inbound domains only.
|
||||||
|
|||||||
@@ -158,3 +158,97 @@ func TestInboundSpamLogRetentionDays(t *testing.T) {
|
|||||||
t.Errorf("after Set(365): %d", days)
|
t.Errorf("after Set(365): %d", days)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestQueryInboundSpamFilters(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
day := func(d, h int) time.Time { return time.Date(2026, 9, d, h, 0, 0, 0, time.UTC) }
|
||||||
|
add := func(domain, to, decision string, at time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
err := st.InsertInboundSpam(InboundSpamEntry{
|
||||||
|
InboundDomain: domain, ClientIP: "203.0.113.7", From: "s@example.net", To: to,
|
||||||
|
Subject: "S " + to, Decision: decision, Engine: SpamEngineRspamd, Detail: "d", At: at,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InsertInboundSpam: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add("a.example", "1@a.example", SpamDecisionAccept, day(20, 23))
|
||||||
|
add("a.example", "2@a.example", SpamDecisionReject, day(21, 0))
|
||||||
|
add("b.example", "3@b.example", SpamDecisionAccept, day(21, 12))
|
||||||
|
add("b.example", "4@b.example", SpamDecisionTempfail, day(22, 0))
|
||||||
|
// Written last but dated earliest: the tailer reads a refusal after the fact.
|
||||||
|
add("a.example", "5@a.example", SpamDecisionReject, day(19, 8))
|
||||||
|
|
||||||
|
to := func(f InboundSpamFilter) string {
|
||||||
|
t.Helper()
|
||||||
|
rows, err := st.QueryInboundSpam(f, 50, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("QueryInboundSpam(%+v): %v", f, err)
|
||||||
|
}
|
||||||
|
n, err := st.CountInboundSpam(f)
|
||||||
|
if err != nil || n != int64(len(rows)) {
|
||||||
|
t.Fatalf("CountInboundSpam(%+v) = %d, %v; the query returned %d", f, n, err, len(rows))
|
||||||
|
}
|
||||||
|
var out string
|
||||||
|
for _, r := range rows {
|
||||||
|
out += r.To[:1]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Newest first by time, not by the order of writing.
|
||||||
|
if got := to(InboundSpamFilter{}); got != "43215" {
|
||||||
|
t.Errorf("no filter = %q, want 43215", got)
|
||||||
|
}
|
||||||
|
if got := to(InboundSpamFilter{Domain: "a.example"}); got != "215" {
|
||||||
|
t.Errorf("domain = %q, want 215", got)
|
||||||
|
}
|
||||||
|
if got := to(InboundSpamFilter{Decision: SpamDecisionReject}); got != "25" {
|
||||||
|
t.Errorf("decision = %q, want 25", got)
|
||||||
|
}
|
||||||
|
// Days are whole UTC days and both ends are in: 21 st is 00:00 up to 23:59.
|
||||||
|
if got := to(InboundSpamFilter{Since: day(21, 15), Until: day(21, 3)}); got != "32" {
|
||||||
|
t.Errorf("one day = %q, want 32", got)
|
||||||
|
}
|
||||||
|
if got := to(InboundSpamFilter{Since: day(22, 0)}); got != "4" {
|
||||||
|
t.Errorf("since = %q, want 4", got)
|
||||||
|
}
|
||||||
|
if got := to(InboundSpamFilter{Until: day(20, 0)}); got != "15" {
|
||||||
|
t.Errorf("until = %q, want 15", got)
|
||||||
|
}
|
||||||
|
// A time in another zone is read as the UTC day it falls on.
|
||||||
|
msk := time.FixedZone("msk", 3*3600)
|
||||||
|
if got := to(InboundSpamFilter{Since: time.Date(2026, 9, 22, 2, 0, 0, 0, msk)}); got != "432" {
|
||||||
|
t.Errorf("since in another zone = %q, want 432 (it is the 21st in UTC)", got)
|
||||||
|
}
|
||||||
|
if got := to(InboundSpamFilter{Domain: "b.example", Decision: SpamDecisionAccept, Since: day(21, 0), Until: day(21, 0)}); got != "3" {
|
||||||
|
t.Errorf("all filters = %q, want 3", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The scope: nil is everything, a set is its domains, an empty set nothing,
|
||||||
|
// and a chosen domain outside the set is nothing too.
|
||||||
|
if got := to(InboundSpamFilter{Domains: []string{"b.example"}}); got != "43" {
|
||||||
|
t.Errorf("scope = %q, want 43", got)
|
||||||
|
}
|
||||||
|
if got := to(InboundSpamFilter{Domains: []string{}}); got != "" {
|
||||||
|
t.Errorf("empty scope = %q, want nothing", got)
|
||||||
|
}
|
||||||
|
if got := to(InboundSpamFilter{Domains: []string{"b.example"}, Domain: "a.example"}); got != "" {
|
||||||
|
t.Errorf("a chosen domain outside the scope = %q, want nothing", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paging.
|
||||||
|
rows, err := st.QueryInboundSpam(InboundSpamFilter{}, 2, 2)
|
||||||
|
if err != nil || len(rows) != 2 || rows[0].To != "2@a.example" || rows[1].To != "1@a.example" {
|
||||||
|
t.Errorf("page 2 of 2 rows = %+v, %v", rows, err)
|
||||||
|
}
|
||||||
|
if rows[0].ID == 0 || !rows[0].CreatedAt.Equal(day(21, 0)) || rows[0].Subject != "S 2@a.example" ||
|
||||||
|
rows[0].Engine != SpamEngineRspamd || rows[0].Detail != "d" || rows[0].ClientIP != "203.0.113.7" || rows[0].From != "s@example.net" {
|
||||||
|
t.Errorf("a row read back = %+v", rows[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// A value is data, not SQL.
|
||||||
|
if got := to(InboundSpamFilter{Domain: "a.example' OR '1'='1"}); got != "" {
|
||||||
|
t.Errorf("injected domain = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ type Config struct {
|
|||||||
// SendLogRetentionEnvDefault is SEND_LOG_RETENTION_DAYS at panel start; used
|
// SendLogRetentionEnvDefault is SEND_LOG_RETENTION_DAYS at panel start; used
|
||||||
// as bootstrap and fallback when the settings row is missing or invalid.
|
// as bootstrap and fallback when the settings row is missing or invalid.
|
||||||
SendLogRetentionEnvDefault int
|
SendLogRetentionEnvDefault int
|
||||||
|
// InboundSpamLogRetentionEnvDefault is INBOUND_SPAM_LOG_RETENTION_DAYS at
|
||||||
|
// panel start, the same bootstrap and fallback for the inbound journal.
|
||||||
|
InboundSpamLogRetentionEnvDefault int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handlers holds dependencies for authenticated panel routes.
|
// Handlers holds dependencies for authenticated panel routes.
|
||||||
@@ -58,6 +61,7 @@ type Handlers struct {
|
|||||||
domains *domain.Service
|
domains *domain.Service
|
||||||
apps *app.Service
|
apps *app.Service
|
||||||
inbound *inbound.Service
|
inbound *inbound.Service
|
||||||
|
lists *inbound.Lists
|
||||||
dmarc *dmarc.Service
|
dmarc *dmarc.Service
|
||||||
cfg Config
|
cfg Config
|
||||||
view *view.Engine
|
view *view.Engine
|
||||||
@@ -72,6 +76,7 @@ func New(
|
|||||||
domains *domain.Service,
|
domains *domain.Service,
|
||||||
apps *app.Service,
|
apps *app.Service,
|
||||||
inboundSvc *inbound.Service,
|
inboundSvc *inbound.Service,
|
||||||
|
lists *inbound.Lists,
|
||||||
dmarcSvc *dmarc.Service,
|
dmarcSvc *dmarc.Service,
|
||||||
cfg Config,
|
cfg Config,
|
||||||
v *view.Engine,
|
v *view.Engine,
|
||||||
@@ -84,6 +89,7 @@ func New(
|
|||||||
domains: domains,
|
domains: domains,
|
||||||
apps: apps,
|
apps: apps,
|
||||||
inbound: inboundSvc,
|
inbound: inboundSvc,
|
||||||
|
lists: lists,
|
||||||
dmarc: dmarcSvc,
|
dmarc: dmarcSvc,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
view: v,
|
view: v,
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/inbound"
|
||||||
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
|
"github.com/mixeme/selfpost/internal/web/view"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Inbound › Log and Inbound › Filter lists. The log is for everyone who reaches
|
||||||
|
// an inbound domain, with the rows of their domains only (requireInbound); the
|
||||||
|
// lists are instance-wide and so the global role's (requireInboundGlobal), and
|
||||||
|
// answer 404 to anyone else, like the rest of what only that role may do.
|
||||||
|
|
||||||
|
const inboundLogPageSize = 50
|
||||||
|
|
||||||
|
// logDay is the form of a day in the log's date filters and in <input
|
||||||
|
// type="date">.
|
||||||
|
const logDay = "2006-01-02"
|
||||||
|
|
||||||
|
// HandleInboundLog renders the Inbound log: the journal of port 25's decisions,
|
||||||
|
// filtered server-side by domain, decision and day, a page at a time. A filter
|
||||||
|
// value that is not one the page offers is dropped rather than refused, and the
|
||||||
|
// domains a domain administrator may read are decided here, before the query
|
||||||
|
// runs - the dropdown only offers them.
|
||||||
|
func (h *Handlers) HandleInboundLog(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p, ok := h.requireInbound(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assigned, err := h.assignedInboundDomains(p)
|
||||||
|
if err != nil {
|
||||||
|
logf("panel: inbound log: %v", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(assigned))
|
||||||
|
offered := make(map[string]bool, len(assigned))
|
||||||
|
for _, d := range assigned {
|
||||||
|
names = append(names, d.Name)
|
||||||
|
offered[d.Name] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
q := r.URL.Query()
|
||||||
|
form := view.InLogFilter{}
|
||||||
|
query := store.InboundSpamFilter{}
|
||||||
|
if d := q.Get("domain"); offered[d] {
|
||||||
|
form.Domain, query.Domain = d, d
|
||||||
|
}
|
||||||
|
switch d := q.Get("decision"); d {
|
||||||
|
case store.SpamDecisionAccept, store.SpamDecisionReject, store.SpamDecisionTempfail, store.SpamDecisionQuarantine:
|
||||||
|
form.Decision, query.Decision = d, d
|
||||||
|
}
|
||||||
|
if t, err := time.Parse(logDay, q.Get("from")); err == nil {
|
||||||
|
form.From, query.Since = t.Format(logDay), t
|
||||||
|
}
|
||||||
|
if t, err := time.Parse(logDay, q.Get("to")); err == nil {
|
||||||
|
form.To, query.Until = t.Format(logDay), t
|
||||||
|
}
|
||||||
|
// A global administrator reads the whole journal, including rows left
|
||||||
|
// behind by a domain that has since been deleted; anyone else only what is
|
||||||
|
// assigned to them, however empty that is.
|
||||||
|
if !p.IsGlobal() {
|
||||||
|
query.Domains = names
|
||||||
|
}
|
||||||
|
|
||||||
|
total, err := h.store.CountInboundSpam(query)
|
||||||
|
if err != nil {
|
||||||
|
logf("panel: inbound log: %v", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastPage := 1
|
||||||
|
if total > 0 {
|
||||||
|
lastPage = int((total + inboundLogPageSize - 1) / inboundLogPageSize)
|
||||||
|
}
|
||||||
|
page := min(parsePage(q.Get("p")), lastPage)
|
||||||
|
rows, err := h.store.QueryInboundSpam(query, inboundLogPageSize, (page-1)*inboundLogPageSize)
|
||||||
|
if err != nil {
|
||||||
|
logf("panel: inbound log: %v", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out := view.NewInLog(h.shellMeta(r), h.inboundSpamLogRetentionDays(), names, form)
|
||||||
|
table := make([]view.InLogRow, len(rows))
|
||||||
|
for i, row := range rows {
|
||||||
|
table[i] = out.NewRow(view.InLogRowInput{
|
||||||
|
At: row.CreatedAt, From: row.From, Client: row.ClientIP, To: row.To, Subject: row.Subject,
|
||||||
|
Decision: row.Decision, Engine: row.Engine, Detail: row.Detail,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
h.view.Render(w, http.StatusOK, "in-log", out.WithRows(table, page, lastPage))
|
||||||
|
}
|
||||||
|
|
||||||
|
// inboundSpamLogRetentionDays returns the effective inbound journal retention
|
||||||
|
// window.
|
||||||
|
func (h *Handlers) inboundSpamLogRetentionDays() int {
|
||||||
|
days, err := h.store.GetInboundSpamLogRetentionDays(h.cfg.InboundSpamLogRetentionEnvDefault)
|
||||||
|
if err != nil {
|
||||||
|
logf("panel: inbound log retention: %v", err)
|
||||||
|
if err := store.ValidateInboundSpamLogRetentionDays(h.cfg.InboundSpamLogRetentionEnvDefault); err == nil {
|
||||||
|
return h.cfg.InboundSpamLogRetentionEnvDefault
|
||||||
|
}
|
||||||
|
return store.InboundSpamLogRetentionDaysDefault
|
||||||
|
}
|
||||||
|
return days
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Filter lists
|
||||||
|
|
||||||
|
func (h *Handlers) requireFilterLists(w http.ResponseWriter, r *http.Request) bool {
|
||||||
|
if _, ok := h.requireInboundGlobal(w, r); !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if h.lists == nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterListForm is what the add form shows: the list and kind it offers (only
|
||||||
|
// values it knows are kept) and the text of the two fields.
|
||||||
|
type filterListForm struct {
|
||||||
|
list, kind, value, note string
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleFilterLists shows the lists. The query fills the add form in - the
|
||||||
|
// Inbound log's Allow / Deny sender links lead here - and only a list and a
|
||||||
|
// kind the form offers are taken; the value is just put in the field.
|
||||||
|
func (h *Handlers) HandleFilterLists(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !h.requireFilterLists(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := r.URL.Query()
|
||||||
|
form := filterListForm{value: q.Get("value")}
|
||||||
|
switch l := q.Get("list"); l {
|
||||||
|
case store.SpamListAllow, store.SpamListDeny:
|
||||||
|
form.list = l
|
||||||
|
}
|
||||||
|
switch k := q.Get("kind"); k {
|
||||||
|
case store.SpamEntryAddress, store.SpamEntryDomain, store.SpamEntryIP:
|
||||||
|
form.kind = k
|
||||||
|
}
|
||||||
|
h.renderFilterLists(w, r, http.StatusOK, filterListsFlash(r), "", form)
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterListsFlash(r *http.Request) string {
|
||||||
|
switch {
|
||||||
|
case r.URL.Query().Has("added"):
|
||||||
|
return "Entry added."
|
||||||
|
case r.URL.Query().Has("removed"):
|
||||||
|
return "Entry removed."
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) renderFilterLists(w http.ResponseWriter, r *http.Request, status int, flash, formErr string, form filterListForm) {
|
||||||
|
entries, err := h.lists.Entries()
|
||||||
|
if err != nil {
|
||||||
|
logf("panel: filter lists: %v", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
on, _, _ := h.inboundFilter()
|
||||||
|
in := make([]view.FilterEntryInput, len(entries))
|
||||||
|
for i, e := range entries {
|
||||||
|
in[i] = view.FilterEntryInput{ID: e.ID, List: e.List, Kind: e.Kind, Value: e.Value, Note: e.Note, Added: e.CreatedAt}
|
||||||
|
}
|
||||||
|
page := view.NewFilterLists(h.shellMeta(r), view.FilterListsInput{FilterOn: on, LastWritten: h.lists.LastWritten()}).
|
||||||
|
WithEntries(in).
|
||||||
|
WithResult(flash, formErr)
|
||||||
|
// A blank list or kind leaves the form on its first choice.
|
||||||
|
if form.list == "" {
|
||||||
|
form.list = store.SpamListDeny
|
||||||
|
}
|
||||||
|
if form.kind == "" {
|
||||||
|
form.kind = store.SpamEntryAddress
|
||||||
|
}
|
||||||
|
h.view.Render(w, status, "in-filter-lists", page.WithForm(form.list, form.kind, form.value, form.note))
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleFilterListAdd adds an entry and rewrites the map files. A refusal - an
|
||||||
|
// entry the lists do not take, or one already on the list - shows the page again
|
||||||
|
// with the message and what was typed.
|
||||||
|
func (h *Handlers) HandleFilterListAdd(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !h.requireFilterLists(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
h.renderFilterLists(w, r, http.StatusBadRequest, "", "Invalid form submission.", filterListForm{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
form := filterListForm{
|
||||||
|
list: r.PostFormValue("list_type"), kind: r.PostFormValue("entry_type"),
|
||||||
|
value: r.PostFormValue("value"), note: r.PostFormValue("note"),
|
||||||
|
}
|
||||||
|
_, err := h.lists.Add(form.list, form.kind, form.value, form.note)
|
||||||
|
var refused *inbound.ValidationError
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
http.Redirect(w, r, "/inbound/filter-lists?added=1", http.StatusSeeOther)
|
||||||
|
case errors.Is(err, store.ErrSpamListEntryExists):
|
||||||
|
h.renderFilterLists(w, r, http.StatusUnprocessableEntity, "", "That entry is already on the list.", form)
|
||||||
|
case errors.As(err, &refused):
|
||||||
|
h.renderFilterLists(w, r, http.StatusUnprocessableEntity, "", refused.Error(), form)
|
||||||
|
default:
|
||||||
|
logf("panel: add filter list entry: %v", err)
|
||||||
|
h.renderFilterLists(w, r, http.StatusInternalServerError, "", "Could not add the entry. Check the logs and try again.", form)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleFilterListDelete removes an entry and rewrites the map files.
|
||||||
|
func (h *Handlers) HandleFilterListDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !h.requireFilterLists(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.lists.Delete(id); err != nil {
|
||||||
|
if errors.Is(err, store.ErrSpamListEntryNotFound) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logf("panel: delete filter list entry %d: %v", id, err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/inbound/filter-lists?removed=1", http.StatusSeeOther)
|
||||||
|
}
|
||||||
@@ -0,0 +1,637 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mixeme/selfpost/internal/inbound"
|
||||||
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
|
"github.com/mixeme/selfpost/internal/web/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Inbound › Log and Inbound › Filter lists, rendered from the real journal and
|
||||||
|
// lists by the handlers. Their markup is held to the mockups by the guards in
|
||||||
|
// internal/web/view; what is checked here is what the handlers read and who may.
|
||||||
|
|
||||||
|
// inboundLogStand is an inbound relay with two domains, a journal row for each
|
||||||
|
// and the lists over a directory the test can read the map files from.
|
||||||
|
type inboundLogStand struct {
|
||||||
|
h *Handlers
|
||||||
|
st *store.Store
|
||||||
|
dir string
|
||||||
|
a, b store.InboundDomain
|
||||||
|
}
|
||||||
|
|
||||||
|
func newInboundLogStand(t *testing.T) inboundLogStand {
|
||||||
|
t.Helper()
|
||||||
|
h, st := inboundHandlers(t)
|
||||||
|
s := inboundLogStand{h: h, st: st, dir: filepath.Join(t.TempDir(), "antispam")}
|
||||||
|
h.lists = inbound.NewLists(st, s.dir)
|
||||||
|
var err error
|
||||||
|
if s.a, err = st.AddInboundDomain("a.example.com"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s.b, err = st.AddInboundDomain("b.example.com"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s.row("a.example.com", "alice@a.example.com", "Hello A", store.SpamDecisionAccept, store.SpamEngineRspamd)
|
||||||
|
s.row("b.example.com", "bob@b.example.com", "Hello B", store.SpamDecisionAccept, store.SpamEngineRspamd)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s inboundLogStand) row(domain, to, subject, decision, engine string) {
|
||||||
|
s.rowAt(domain, to, subject, decision, engine, time.Time{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s inboundLogStand) rowAt(domain, to, subject, decision, engine string, at time.Time) {
|
||||||
|
err := s.st.InsertInboundSpam(store.InboundSpamEntry{
|
||||||
|
InboundDomain: domain, ClientIP: "203.0.113.9", From: "sender@example.net", To: to, Subject: subject,
|
||||||
|
Decision: decision, Engine: engine, Detail: "detail of " + subject, At: at,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// get runs a handler over a GET as p.
|
||||||
|
func (s inboundLogStand) get(h http.HandlerFunc, target string, p auth.Principal) *httptest.ResponseRecorder {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h(rec, auth.RequestWithPrincipal(httptest.NewRequest(http.MethodGet, target, nil), p))
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s inboundLogStand) owner(t *testing.T, username string, domains ...store.InboundDomain) auth.Principal {
|
||||||
|
t.Helper()
|
||||||
|
var ids []int64
|
||||||
|
for _, d := range domains {
|
||||||
|
ids = append(ids, d.ID)
|
||||||
|
}
|
||||||
|
return userWith(t, s.st, username, store.RoleDomain, store.Reach{InboundDomainIDs: ids})
|
||||||
|
}
|
||||||
|
|
||||||
|
// The global role reads the whole journal, is offered every domain, and is given
|
||||||
|
// the way to correct a decision and to change how long rows are kept.
|
||||||
|
func TestInboundLogForTheGlobalRole(t *testing.T) {
|
||||||
|
s := newInboundLogStand(t)
|
||||||
|
s.row("a.example.com", "carol@a.example.com", "Quarantined", store.SpamDecisionQuarantine, store.SpamEngineRspamd)
|
||||||
|
if err := s.st.SetInboundSpamLogRetentionDays(30); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rec := s.get(s.h.HandleInboundLog, "/inbound/log", globalPrincipal)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET /inbound/log = %d:\n%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
body := rec.Body.String()
|
||||||
|
has(t, "log", body,
|
||||||
|
`<h1 class="title is-3">Inbound log</h1>`,
|
||||||
|
`Rows older than 30 days are deleted — change that in <a href="/server/settings">Settings</a>.`,
|
||||||
|
`alice@<wbr>a.example.com`, `bob@<wbr>b.example.com`, `Hello A`, `Hello B`,
|
||||||
|
`<option value="a.example.com">a.example.com</option>`, `<option value="b.example.com">b.example.com</option>`,
|
||||||
|
`<span class="tag is-success is-light">accept</span>`, `<span class="tag is-primary is-light">quarantine</span>`,
|
||||||
|
`<span class="sp-mono sp-muted sp-small">203.0.113.9</span>`,
|
||||||
|
`rspamd · detail of Hello A`,
|
||||||
|
`<a href="/inbound/filter-lists?list=deny&kind=address&value=sender%40example.net">Deny sender</a>`,
|
||||||
|
`<a href="/inbound/filter-lists?list=allow&kind=address&value=sender%40example.net">Allow sender</a>`,
|
||||||
|
`Page 1 of 1`, `Allow / Deny sender opens <a href="/inbound/filter-lists">Filter lists</a>`,
|
||||||
|
)
|
||||||
|
// Newest first: the last row written is the first on the page.
|
||||||
|
if strings.Index(body, `Quarantined`) > strings.Index(body, `Hello B`) || strings.Index(body, `Hello B`) > strings.Index(body, `Hello A`) {
|
||||||
|
t.Error("the rows are not newest first")
|
||||||
|
}
|
||||||
|
lacks(t, "log", body, `data-poll`, `hx-get`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A domain administrator reads the rows of the inbound domains assigned to them,
|
||||||
|
// is offered those domains alone, and is not given the lists or the setting.
|
||||||
|
func TestInboundLogForADomainAdministrator(t *testing.T) {
|
||||||
|
s := newInboundLogStand(t)
|
||||||
|
p := s.owner(t, "owns-a", s.a)
|
||||||
|
body := s.get(s.h.HandleInboundLog, "/inbound/log", p).Body.String()
|
||||||
|
has(t, "log", body, `Hello A`, `<option value="a.example.com">a.example.com</option>`, `Rows older than 90 days are deleted.`)
|
||||||
|
lacks(t, "log", body, `Hello B`, `b.example.com`, `href="/server/settings"`, `/inbound/filter-lists`,
|
||||||
|
`Deny sender`, `Allow sender`, `Allow / Deny`)
|
||||||
|
|
||||||
|
// A domain filter outside their reach is dropped, not honoured.
|
||||||
|
body = s.get(s.h.HandleInboundLog, "/inbound/log?domain=b.example.com", p).Body.String()
|
||||||
|
has(t, "log", body, `Hello A`)
|
||||||
|
lacks(t, "log", body, `Hello B`, `b.example.com`)
|
||||||
|
|
||||||
|
// One with All sees every domain, one with none sees no log at all.
|
||||||
|
all := userWith(t, s.st, "owns-all", store.RoleDomain, store.Reach{AllInbound: true})
|
||||||
|
body = s.get(s.h.HandleInboundLog, "/inbound/log", all).Body.String()
|
||||||
|
has(t, "log", body, `Hello A`, `Hello B`)
|
||||||
|
lacks(t, "log", body, `Deny sender`)
|
||||||
|
|
||||||
|
outboundOnly := userWith(t, s.st, "outbound-only", store.RoleDomain, store.Reach{AllDomains: true})
|
||||||
|
if rec := s.get(s.h.HandleInboundLog, "/inbound/log", outboundOnly); rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("an outbound-only administrator got %d, want 404", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A domain administrator whose assigned domain has been deleted since has nothing
|
||||||
|
// to read, not everything.
|
||||||
|
func TestInboundLogOfADeletedDomainIsEmptyForItsAdministrator(t *testing.T) {
|
||||||
|
s := newInboundLogStand(t)
|
||||||
|
p := s.owner(t, "owns-a", s.a)
|
||||||
|
both := s.owner(t, "owns-both", s.a, s.b)
|
||||||
|
if err := s.st.DeleteInboundDomain(s.a.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body := s.get(s.h.HandleInboundLog, "/inbound/log", p).Body.String()
|
||||||
|
has(t, "log", body, `No messages logged yet.`)
|
||||||
|
lacks(t, "log", body, `Hello A`, `Hello B`)
|
||||||
|
body = s.get(s.h.HandleInboundLog, "/inbound/log", both).Body.String()
|
||||||
|
has(t, "log", body, `Hello B`)
|
||||||
|
lacks(t, "log", body, `Hello A`)
|
||||||
|
// The global role still reads what the deleted domain left in the journal.
|
||||||
|
has(t, "log", s.get(s.h.HandleInboundLog, "/inbound/log", globalPrincipal).Body.String(), `Hello A`, `Hello B`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInboundLogFilters(t *testing.T) {
|
||||||
|
s := newInboundLogStand(t)
|
||||||
|
day := func(d, h int) time.Time { return time.Date(2026, 9, d, h, 0, 0, 0, time.UTC) }
|
||||||
|
s.rowAt("a.example.com", "r1@a.example.com", "Rejected on the 10th", store.SpamDecisionReject, store.SpamEnginePostfix, day(10, 23))
|
||||||
|
s.rowAt("a.example.com", "r2@a.example.com", "Rejected on the 11th", store.SpamDecisionReject, store.SpamEnginePostfix, day(11, 0))
|
||||||
|
s.rowAt("b.example.com", "r3@b.example.com", "Tempfail on the 11th", store.SpamDecisionTempfail, store.SpamEngineRspamd, day(11, 23))
|
||||||
|
s.rowAt("a.example.com", "r4@a.example.com", "Accepted on the 12th", store.SpamDecisionAccept, store.SpamEngineRspamd, day(12, 0))
|
||||||
|
|
||||||
|
rows := func(query string) string {
|
||||||
|
t.Helper()
|
||||||
|
rec := s.get(s.h.HandleInboundLog, "/inbound/log"+query, globalPrincipal)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET /inbound/log%s = %d", query, rec.Code)
|
||||||
|
}
|
||||||
|
var got []string
|
||||||
|
body := rec.Body.String()
|
||||||
|
for _, n := range []string{"10th", "11th", "12th"} {
|
||||||
|
for _, kind := range []string{"Rejected", "Tempfail", "Accepted"} {
|
||||||
|
if strings.Contains(body, kind+" on the "+n) {
|
||||||
|
got = append(got, kind[:1]+n[:2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(got, " ")
|
||||||
|
}
|
||||||
|
for query, want := range map[string]string{
|
||||||
|
"?decision=reject": "R10 R11",
|
||||||
|
"?domain=b.example.com": "T11",
|
||||||
|
"?from=2026-09-11&to=2026-09-11": "R11 T11",
|
||||||
|
"?from=2026-09-12": "A12",
|
||||||
|
"?to=2026-09-10": "R10",
|
||||||
|
"?domain=a.example.com&decision=reject&from=2026-09-11": "R11",
|
||||||
|
// A value that is not one the page offers is ignored.
|
||||||
|
"?decision=bounce": "R10 R11 T11 A12",
|
||||||
|
"?from=yesterday&to=2026-13-45": "R10 R11 T11 A12",
|
||||||
|
"?domain=nobody.example.com": "R10 R11 T11 A12",
|
||||||
|
"?decision=reject%27+OR+%271%27%3D%271": "R10 R11 T11 A12",
|
||||||
|
"?domain=a.example.com&domain=b.example.com": "R10 R11 A12",
|
||||||
|
} {
|
||||||
|
if got := rows(query); got != want {
|
||||||
|
t.Errorf("GET /inbound/log%s shows %q, want %q", query, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The choice made is the one shown, and the page leads on with it.
|
||||||
|
body := s.get(s.h.HandleInboundLog, "/inbound/log?domain=a.example.com&decision=reject&from=2026-09-11&to=2026-09-12", globalPrincipal).Body.String()
|
||||||
|
has(t, "log", body, `<option value="a.example.com" selected>`, `<option value="reject" selected>`,
|
||||||
|
`name="from" value="2026-09-11"`, `name="to" value="2026-09-12"`)
|
||||||
|
body = s.get(s.h.HandleInboundLog, "/inbound/log?decision=quarantine", globalPrincipal).Body.String()
|
||||||
|
has(t, "log", body, `No messages match these filters.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInboundLogPages(t *testing.T) {
|
||||||
|
s := newInboundLogStand(t)
|
||||||
|
for i := 0; i < inboundLogPageSize; i++ {
|
||||||
|
s.row("a.example.com", fmt.Sprintf("u%d@a.example.com", i), fmt.Sprintf("Message %02d", i), store.SpamDecisionAccept, store.SpamEngineRspamd)
|
||||||
|
}
|
||||||
|
// 51 rows with the one the stand wrote: 50 and 1.
|
||||||
|
first := s.get(s.h.HandleInboundLog, "/inbound/log?domain=a.example.com", globalPrincipal).Body.String()
|
||||||
|
has(t, "page 1", first, `Page 1 of 2`, `<a href="/inbound/log?domain=a.example.com&p=2">Older →</a>`, `Message 49`, `Message 00`)
|
||||||
|
lacks(t, "page 1", first, `← Newer`, `Hello A`)
|
||||||
|
if got := strings.Count(first, `<tr><td`); got != inboundLogPageSize {
|
||||||
|
t.Errorf("page 1 has %d rows, want %d", got, inboundLogPageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
second := s.get(s.h.HandleInboundLog, "/inbound/log?domain=a.example.com&p=2", globalPrincipal).Body.String()
|
||||||
|
has(t, "page 2", second, `Page 2 of 2`, `<a href="/inbound/log?domain=a.example.com">← Newer</a>`, `Hello A`)
|
||||||
|
lacks(t, "page 2", second, `Older →`, `Message 49`, `Message 00`)
|
||||||
|
if got := strings.Count(second, `<tr><td`); got != 1 {
|
||||||
|
t.Errorf("page 2 has %d rows, want 1", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A page past the end, or nonsense, is the last page, or the first.
|
||||||
|
has(t, "page 9", s.get(s.h.HandleInboundLog, "/inbound/log?domain=a.example.com&p=9", globalPrincipal).Body.String(), `Page 2 of 2`, `Hello A`)
|
||||||
|
has(t, "page x", s.get(s.h.HandleInboundLog, "/inbound/log?domain=a.example.com&p=x", globalPrincipal).Body.String(), `Page 1 of 2`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInboundLogEmptyAndOff(t *testing.T) {
|
||||||
|
h, _ := inboundHandlers(t)
|
||||||
|
s := inboundLogStand{h: h}
|
||||||
|
body := s.get(h.HandleInboundLog, "/inbound/log", globalPrincipal).Body.String()
|
||||||
|
has(t, "empty log", body, `No messages logged yet.`)
|
||||||
|
lacks(t, "empty log", body, `<table`, `Page 1`)
|
||||||
|
|
||||||
|
h.cfg.InboundEnabled = false
|
||||||
|
if rec := s.get(h.HandleInboundLog, "/inbound/log", globalPrincipal); rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("with the inbound relay off GET /inbound/log = %d, want 404", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What a sender or a server wrote is text on the page.
|
||||||
|
func TestInboundLogEscapesWhatSendersWrote(t *testing.T) {
|
||||||
|
s := newInboundLogStand(t)
|
||||||
|
err := s.st.InsertInboundSpam(store.InboundSpamEntry{
|
||||||
|
InboundDomain: "a.example.com", ClientIP: "203.0.113.9", From: `"><img src=x onerror=alert(1)>@e.example`,
|
||||||
|
To: "x@a.example.com", Subject: `<script>alert(3)</script>`, Decision: store.SpamDecisionReject,
|
||||||
|
Engine: store.SpamEnginePostfix, Detail: `550 <b>no</b>`,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body := s.get(s.h.HandleInboundLog, "/inbound/log", globalPrincipal).Body.String()
|
||||||
|
lacks(t, "log", body, `<script>alert`, `<img src=x`, `<b>no</b>`)
|
||||||
|
has(t, "log", body, `<script>alert(3)</script>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Filter lists
|
||||||
|
|
||||||
|
func (s inboundLogStand) post(h http.HandlerFunc, target string, form url.Values, p auth.Principal) *httptest.ResponseRecorder {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
if rest, ok := strings.CutPrefix(target, "/inbound/filter-lists/"); ok {
|
||||||
|
id, _, _ := strings.Cut(rest, "/")
|
||||||
|
req.SetPathValue("id", id)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h(rec, auth.RequestWithPrincipal(req, p))
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s inboundLogStand) mapFile(t *testing.T, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
b, err := os.ReadFile(filepath.Join(s.dir, name))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read %s: %v", name, err)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func entry(list, kind, value, note string) url.Values {
|
||||||
|
return url.Values{"list_type": {list}, "entry_type": {kind}, "value": {value}, "note": {note}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The lists are the global role's: everything about them is 404 to a domain
|
||||||
|
// administrator and while the inbound relay is off, and nothing is written.
|
||||||
|
func TestFilterListsAreForTheGlobalRoleOnly(t *testing.T) {
|
||||||
|
s := newInboundLogStand(t)
|
||||||
|
e, err := s.h.lists.Add("deny", "domain", "bulk.example", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ops := s.owner(t, "owns-a", s.a)
|
||||||
|
all := userWith(t, s.st, "owns-all", store.RoleDomain, store.Reach{AllInbound: true, AllDomains: true})
|
||||||
|
for _, p := range []auth.Principal{ops, all} {
|
||||||
|
if rec := s.get(s.h.HandleFilterLists, "/inbound/filter-lists", p); rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("GET as %s = %d, want 404", p.Username, rec.Code)
|
||||||
|
}
|
||||||
|
if rec := s.post(s.h.HandleFilterListAdd, "/inbound/filter-lists", entry("deny", "domain", "evil.example", ""), p); rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("POST as %s = %d, want 404", p.Username, rec.Code)
|
||||||
|
}
|
||||||
|
if rec := s.post(s.h.HandleFilterListDelete, "/inbound/filter-lists/"+itoa(e.ID)+"/delete", nil, p); rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("POST delete as %s = %d, want 404", p.Username, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if list, _ := s.h.lists.Entries(); len(list) != 1 {
|
||||||
|
t.Errorf("a domain administrator changed the lists: %+v", list)
|
||||||
|
}
|
||||||
|
if got := s.mapFile(t, "deny_domain.map"); strings.Contains(got, "evil.example") || !strings.Contains(got, "bulk.example") {
|
||||||
|
t.Errorf("deny_domain.map = %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.h.cfg.InboundEnabled = false
|
||||||
|
if rec := s.get(s.h.HandleFilterLists, "/inbound/filter-lists", globalPrincipal); rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("with the inbound relay off GET = %d, want 404", rec.Code)
|
||||||
|
}
|
||||||
|
if rec := s.post(s.h.HandleFilterListAdd, "/inbound/filter-lists", entry("deny", "domain", "evil.example", ""), globalPrincipal); rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("with the inbound relay off POST = %d, want 404", rec.Code)
|
||||||
|
}
|
||||||
|
if rec := s.post(s.h.HandleFilterListDelete, "/inbound/filter-lists/"+itoa(e.ID)+"/delete", nil, globalPrincipal); rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("with the inbound relay off POST delete = %d, want 404", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing to write the lists to is nothing to show.
|
||||||
|
s.h.cfg.InboundEnabled, s.h.lists = true, nil
|
||||||
|
if rec := s.get(s.h.HandleFilterLists, "/inbound/filter-lists", globalPrincipal); rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("without lists GET = %d, want 404", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterListsAddAndRemove(t *testing.T) {
|
||||||
|
s := newInboundLogStand(t)
|
||||||
|
empty := s.get(s.h.HandleFilterLists, "/inbound/filter-lists", globalPrincipal).Body.String()
|
||||||
|
has(t, "empty lists", empty, `<h1 class="title is-3">Filter lists</h1>`, `<h2>Deny</h2><div class="sp-end">0 entries · wins over Allow</div>`,
|
||||||
|
`<h2>Allow</h2><div class="sp-end">0 entries</div>`, `No sender is turned away by hand.`, `No sender is let through by hand.`)
|
||||||
|
lacks(t, "empty lists", empty, `<table`)
|
||||||
|
|
||||||
|
// The domain is normalised, written to the file of its list and kind, and the
|
||||||
|
// page shows it.
|
||||||
|
rec := s.post(s.h.HandleFilterListAdd, "/inbound/filter-lists", entry("deny", "domain", " @Bulk.Example ", " newsletter "), globalPrincipal)
|
||||||
|
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/inbound/filter-lists?added=1" {
|
||||||
|
t.Fatalf("add = %d to %q:\n%s", rec.Code, rec.Header().Get("Location"), rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := s.mapFile(t, "deny_domain.map"); !strings.Contains(got, "\nbulk.example\n") {
|
||||||
|
t.Errorf("deny_domain.map = %q", got)
|
||||||
|
}
|
||||||
|
if got := s.mapFile(t, "allow_domain.map"); strings.Contains(got, "bulk.example") {
|
||||||
|
t.Errorf("allow_domain.map = %q", got)
|
||||||
|
}
|
||||||
|
rec = s.post(s.h.HandleFilterListAdd, "/inbound/filter-lists", entry("allow", "ip", "198.51.100.77/24", ""), globalPrincipal)
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("add ip = %d:\n%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := s.mapFile(t, "allow_ip.map"); !strings.Contains(got, "\n198.51.100.0/24\n") {
|
||||||
|
t.Errorf("allow_ip.map = %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
page := s.get(s.h.HandleFilterLists, "/inbound/filter-lists?added=1", globalPrincipal).Body.String()
|
||||||
|
has(t, "lists", page,
|
||||||
|
`<div class="notification is-success is-light">`, `Entry added.`,
|
||||||
|
`<h2>Deny</h2><div class="sp-end">1 entry · wins over Allow</div>`, `<h2>Allow</h2><div class="sp-end">1 entry</div>`,
|
||||||
|
`bulk.example <span class="tag is-light">domain</span></td><td class="sp-muted">newsletter</td>`,
|
||||||
|
`198.51.100.0/24 <span class="tag is-light">ip</span></td><td class="sp-muted">—</td>`,
|
||||||
|
`<td class="sp-muted sp-nowrap sp-small">`+time.Now().UTC().Format("2006-01-02")+`</td>`,
|
||||||
|
)
|
||||||
|
lacks(t, "lists", page, `No sender is turned away`)
|
||||||
|
|
||||||
|
// Remove the deny entry: gone from the page and from the file, the allow
|
||||||
|
// entry untouched.
|
||||||
|
entries, _ := s.h.lists.Entries()
|
||||||
|
var denyID int64
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.List == "deny" {
|
||||||
|
denyID = e.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
has(t, "lists", page, `action="/inbound/filter-lists/`+itoa(denyID)+`/delete"`)
|
||||||
|
rec = s.post(s.h.HandleFilterListDelete, "/inbound/filter-lists/"+itoa(denyID)+"/delete", nil, globalPrincipal)
|
||||||
|
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/inbound/filter-lists?removed=1" {
|
||||||
|
t.Fatalf("delete = %d to %q", rec.Code, rec.Header().Get("Location"))
|
||||||
|
}
|
||||||
|
if got := s.mapFile(t, "deny_domain.map"); strings.Contains(got, "bulk.example") {
|
||||||
|
t.Errorf("deny_domain.map still holds the entry: %q", got)
|
||||||
|
}
|
||||||
|
if got := s.mapFile(t, "allow_ip.map"); !strings.Contains(got, "198.51.100.0/24") {
|
||||||
|
t.Errorf("allow_ip.map lost its entry: %q", got)
|
||||||
|
}
|
||||||
|
page = s.get(s.h.HandleFilterLists, "/inbound/filter-lists?removed=1", globalPrincipal).Body.String()
|
||||||
|
has(t, "lists", page, `Entry removed.`, `<h2>Deny</h2><div class="sp-end">0 entries`)
|
||||||
|
lacks(t, "lists", page, `bulk.example <span`)
|
||||||
|
|
||||||
|
// An entry that is not there, or an id that is not one.
|
||||||
|
for _, id := range []string{itoa(denyID), "9999", "x", "0", "-1"} {
|
||||||
|
if rec := s.post(s.h.HandleFilterListDelete, "/inbound/filter-lists/"+id+"/delete", nil, globalPrincipal); rec.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("delete %q = %d, want 404", id, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A refusal comes back with 422 on the same page, the message between the head
|
||||||
|
// and the form and what was typed kept in it; nothing is written.
|
||||||
|
func TestFilterListsRefusals(t *testing.T) {
|
||||||
|
s := newInboundLogStand(t)
|
||||||
|
if rec := s.post(s.h.HandleFilterListAdd, "/inbound/filter-lists", entry("deny", "address", "win@lottery.example", ""), globalPrincipal); rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("add = %d", rec.Code)
|
||||||
|
}
|
||||||
|
before := s.mapFile(t, "deny_from.map")
|
||||||
|
|
||||||
|
for _, c := range []struct {
|
||||||
|
name string
|
||||||
|
form url.Values
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"duplicate", entry("deny", "address", "WIN@lottery.example", "again"), "That entry is already on the list."},
|
||||||
|
{"not an address", entry("deny", "address", "not-an-address", ""), "is not a valid email address"},
|
||||||
|
{"not a network", entry("allow", "ip", "192.0.2.0/99", "kept note"), "is not a network in CIDR form"},
|
||||||
|
{"no kind", entry("deny", "everything", "x", ""), "choose what the entry is"},
|
||||||
|
{"no list", entry("maybe", "domain", "x.example", ""), "choose the allow or the deny list"},
|
||||||
|
{"empty", entry("deny", "domain", " ", ""), "domain is required"},
|
||||||
|
{"long note", entry("deny", "domain", "x.example", strings.Repeat("n", 201)), "note must be at most 200 characters"},
|
||||||
|
} {
|
||||||
|
rec := s.post(s.h.HandleFilterListAdd, "/inbound/filter-lists", c.form, globalPrincipal)
|
||||||
|
if rec.Code != http.StatusUnprocessableEntity {
|
||||||
|
t.Errorf("%s: %d, want 422:\n%s", c.name, rec.Code, rec.Body.String())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
body := rec.Body.String()
|
||||||
|
has(t, c.name, body, `<div class="notification is-danger is-light">`, c.want, `value="`+c.form.Get("value")+`"`)
|
||||||
|
if c.form.Get("list_type") == "allow" || c.form.Get("list_type") == "deny" {
|
||||||
|
has(t, c.name, body, `<option value="`+c.form.Get("list_type")+`" selected>`)
|
||||||
|
}
|
||||||
|
if c.form.Get("note") != "" && len(c.form.Get("note")) < 100 {
|
||||||
|
has(t, c.name, body, `value="`+c.form.Get("note")+`"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := s.mapFile(t, "deny_from.map"); got != before {
|
||||||
|
t.Errorf("a refused entry changed deny_from.map:\n%s", got)
|
||||||
|
}
|
||||||
|
if e, _ := s.h.lists.Entries(); len(e) != 1 {
|
||||||
|
t.Errorf("a refused entry was stored: %+v", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// What was typed is escaped when it comes back.
|
||||||
|
rec := s.post(s.h.HandleFilterListAdd, "/inbound/filter-lists", entry("deny", "address", `"><script>1</script>`, `<i>x</i>`), globalPrincipal)
|
||||||
|
if rec.Code != http.StatusUnprocessableEntity {
|
||||||
|
t.Fatalf("hostile entry = %d", rec.Code)
|
||||||
|
}
|
||||||
|
lacks(t, "refused form", rec.Body.String(), `<script>1`, `<i>x</i>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The log's Allow / Deny sender links fill the form in. Only a list and a kind
|
||||||
|
// the form offers are taken; the value is text.
|
||||||
|
func TestFilterListsPrefillFromTheLog(t *testing.T) {
|
||||||
|
s := newInboundLogStand(t)
|
||||||
|
get := func(query string) string {
|
||||||
|
rec := s.get(s.h.HandleFilterLists, "/inbound/filter-lists"+query, globalPrincipal)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("GET %s = %d", query, rec.Code)
|
||||||
|
}
|
||||||
|
return rec.Body.String()
|
||||||
|
}
|
||||||
|
body := get("?list=allow&kind=address&value=billing%40partner.example")
|
||||||
|
has(t, "prefilled", body, `<option value="allow" selected>`, `<option value="address" selected>`,
|
||||||
|
`id="value" name="value" value="billing@partner.example"`)
|
||||||
|
lacks(t, "prefilled", body, `<option value="deny" selected>`)
|
||||||
|
|
||||||
|
body = get("?list=allow&kind=domain&value=partner.example")
|
||||||
|
has(t, "prefilled", body, `<option value="allow" selected>`, `<option value="domain" selected>`, `value="partner.example"`)
|
||||||
|
|
||||||
|
// Unknown choices leave the form on its first; the value is still put in.
|
||||||
|
body = get("?list=everything&kind=whatever&value=x%40y.example")
|
||||||
|
has(t, "unknown choices", body, `<option value="deny" selected>`, `<option value="address" selected>`, `value="x@y.example"`)
|
||||||
|
|
||||||
|
// The value cannot leave its attribute.
|
||||||
|
body = get(`?value=` + url.QueryEscape(`"><script>alert(1)</script>`))
|
||||||
|
lacks(t, "hostile value", body, `<script>alert`)
|
||||||
|
has(t, "hostile value", body, `value=""><script>alert(1)</script>"`)
|
||||||
|
|
||||||
|
// Nothing in the query is a flash.
|
||||||
|
lacks(t, "plain", get(""), `notification`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The log's links and the form agree: what the log links to is what the form
|
||||||
|
// accepts, for every kind of sender it can name.
|
||||||
|
func TestFilterListsAcceptWhatTheLogLinksTo(t *testing.T) {
|
||||||
|
s := newInboundLogStand(t)
|
||||||
|
log := s.get(s.h.HandleInboundLog, "/inbound/log", globalPrincipal).Body.String()
|
||||||
|
has(t, "log", log, `href="/inbound/filter-lists?list=deny&kind=address&value=sender%40example.net"`)
|
||||||
|
body := s.get(s.h.HandleFilterLists, "/inbound/filter-lists?list=deny&kind=address&value=sender%40example.net", globalPrincipal).Body.String()
|
||||||
|
has(t, "form", body, `value="sender@example.net"`)
|
||||||
|
if rec := s.post(s.h.HandleFilterListAdd, "/inbound/filter-lists", entry("deny", "address", "sender@example.net", ""), globalPrincipal); rec.Code != http.StatusSeeOther {
|
||||||
|
t.Errorf("the entry the log leads to was refused: %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Box 04 says whether anything reads the lists and when they were last written.
|
||||||
|
func TestFilterListsWhereTheyGo(t *testing.T) {
|
||||||
|
s := newInboundLogStand(t)
|
||||||
|
|
||||||
|
// Filter off: the lists are kept, the box says nothing reads them, and the
|
||||||
|
// files have never been written.
|
||||||
|
body := s.get(s.h.HandleFilterLists, "/inbound/filter-lists", globalPrincipal).Body.String()
|
||||||
|
has(t, "filter off", body, `<h2>Where the lists go</h2>`, `<span class="tag is-light">not applied</span>`,
|
||||||
|
`Nothing — the spam filter is off. The lists are kept and can be edited; nothing reads them until <code>INBOUND_ANTISPAM_MILTER</code> is set.`,
|
||||||
|
`<dt>Last written</dt><dd>never</dd>`, `Deny wins, whichever entries they are.`)
|
||||||
|
lacks(t, "filter off", body, `is-success is-light">applied`, `rspamd, from map files`)
|
||||||
|
|
||||||
|
// Filter on, and the files written.
|
||||||
|
s.h.cfg.InboundAntispamMilter = "inet:antispam:11332"
|
||||||
|
if err := s.h.lists.Sync(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
stamp := time.Date(2026, 9, 11, 8, 40, 0, 0, time.UTC)
|
||||||
|
for _, name := range []string{"allow_ip.map", "allow_from.map", "allow_domain.map", "deny_ip.map", "deny_from.map", "deny_domain.map"} {
|
||||||
|
if err := os.Chtimes(filepath.Join(s.dir, name), stamp, stamp); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body = s.get(s.h.HandleFilterLists, "/inbound/filter-lists", globalPrincipal).Body.String()
|
||||||
|
has(t, "filter on", body, `<span class="tag is-success is-light">applied</span>`,
|
||||||
|
`rspamd, from map files it rereads within seconds of a change`, `<dt>Last written</dt><dd>2026-09-11 08:40 UTC</dd>`,
|
||||||
|
`/data/antispam/`)
|
||||||
|
lacks(t, "filter on", body, `not applied`, `Nothing — the spam filter is off`, `INBOUND_ANTISPAM_MILTER`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Settings and Help
|
||||||
|
|
||||||
|
func settingsWithInbound(t *testing.T, on bool) *Handlers {
|
||||||
|
t.Helper()
|
||||||
|
h, _ := settingsServer(t)
|
||||||
|
h.cfg.InboundEnabled = on
|
||||||
|
h.cfg.InboundSpamLogRetentionEnvDefault = 90
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettingsShowsAndSavesTheInboundRetention(t *testing.T) {
|
||||||
|
h := settingsWithInbound(t, true)
|
||||||
|
body := getBody(t, h.HandleServerSettings, "/server/settings")
|
||||||
|
has(t, "settings", body, `Keep outbound log rows, days`, `Keep inbound log rows, days`,
|
||||||
|
`name="inbound_spam_log_retention_days"`, `value="90"`,
|
||||||
|
`The inbound log also never holds more than 10 000 rows`)
|
||||||
|
|
||||||
|
if err := h.store.SetInboundSpamLogRetentionDays(45); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
has(t, "settings", getBody(t, h.HandleServerSettings, "/server/settings"), `name="inbound_spam_log_retention_days" type="number" min="7" max="365" step="1" value="45"`)
|
||||||
|
|
||||||
|
rec := postFormAs(h.HandleServerSettings, globalPrincipal, "/server/settings", nil,
|
||||||
|
url.Values{"send_log_retention_days": {"120"}, "inbound_spam_log_retention_days": {"200"}})
|
||||||
|
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/server/settings?saved=1" {
|
||||||
|
t.Fatalf("POST /server/settings = %d to %q:\n%s", rec.Code, rec.Header().Get("Location"), rec.Body.String())
|
||||||
|
}
|
||||||
|
if got, _ := h.store.GetSendLogRetentionDays(90); got != 120 {
|
||||||
|
t.Errorf("outbound retention = %d, want 120", got)
|
||||||
|
}
|
||||||
|
if got, _ := h.store.GetInboundSpamLogRetentionDays(90); got != 200 {
|
||||||
|
t.Errorf("inbound retention = %d, want 200", got)
|
||||||
|
}
|
||||||
|
// The Inbound log's lead reads the same setting.
|
||||||
|
if got := h.inboundSpamLogRetentionDays(); got != 200 {
|
||||||
|
t.Errorf("inboundSpamLogRetentionDays = %d, want 200", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettingsRefuseAnInboundRetentionOutOfRange(t *testing.T) {
|
||||||
|
h := settingsWithInbound(t, true)
|
||||||
|
for _, bad := range []string{"3", "366", "nine", ""} {
|
||||||
|
rec := postFormAs(h.HandleServerSettings, globalPrincipal, "/server/settings", nil,
|
||||||
|
url.Values{"send_log_retention_days": {"120"}, "inbound_spam_log_retention_days": {bad}})
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("POST (inbound %q) = %d, want 400:\n%s", bad, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if bad == "3" && !strings.Contains(rec.Body.String(), "between 7 and 365") {
|
||||||
|
t.Errorf("no range in the refusal:\n%s", rec.Body.String())
|
||||||
|
}
|
||||||
|
if bad == "nine" && !strings.Contains(rec.Body.String(), "Inbound log retention must be a whole number of days.") {
|
||||||
|
t.Errorf("no sentence in the refusal:\n%s", rec.Body.String())
|
||||||
|
}
|
||||||
|
// The outbound value that was fine is shown as typed, the bad one as it was.
|
||||||
|
has(t, "refused form", rec.Body.String(), `name="send_log_retention_days" type="number" min="7" max="365" step="1" value="120"`)
|
||||||
|
}
|
||||||
|
// Nothing was saved, not even the outbound value that was fine.
|
||||||
|
if got, _ := h.store.GetSendLogRetentionDays(90); got != 90 {
|
||||||
|
t.Errorf("a refused form changed the outbound retention to %d", got)
|
||||||
|
}
|
||||||
|
if got, _ := h.store.GetInboundSpamLogRetentionDays(90); got != 90 {
|
||||||
|
t.Errorf("a refused form changed the inbound retention to %d", got)
|
||||||
|
}
|
||||||
|
// A bad outbound value refuses the whole form too.
|
||||||
|
rec := postFormAs(h.HandleServerSettings, globalPrincipal, "/server/settings", nil,
|
||||||
|
url.Values{"send_log_retention_days": {"3"}, "inbound_spam_log_retention_days": {"200"}})
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("POST (outbound 3) = %d", rec.Code)
|
||||||
|
}
|
||||||
|
if got, _ := h.store.GetInboundSpamLogRetentionDays(90); got != 90 {
|
||||||
|
t.Errorf("the inbound retention was saved by a refused form: %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With the relay off there is no inbound journal to keep: the field is not on the
|
||||||
|
// page and a value posted for it is not read.
|
||||||
|
func TestSettingsHaveNoInboundRetentionWithTheRelayOff(t *testing.T) {
|
||||||
|
h := settingsWithInbound(t, false)
|
||||||
|
body := getBody(t, h.HandleServerSettings, "/server/settings")
|
||||||
|
has(t, "settings", body, `Keep outbound log rows, days`)
|
||||||
|
lacks(t, "settings", body, `inbound_spam_log_retention_days`, `Keep inbound log rows`)
|
||||||
|
|
||||||
|
rec := postFormAs(h.HandleServerSettings, globalPrincipal, "/server/settings", nil,
|
||||||
|
url.Values{"send_log_retention_days": {"120"}, "inbound_spam_log_retention_days": {"3"}})
|
||||||
|
if rec.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("POST = %d:\n%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if got, _ := h.store.GetSendLogRetentionDays(90); got != 120 {
|
||||||
|
t.Errorf("outbound retention = %d, want 120", got)
|
||||||
|
}
|
||||||
|
if raw, _ := h.store.GetSetting(store.InboundSpamLogRetentionDaysKey); raw != "" {
|
||||||
|
t.Errorf("an inbound retention was saved with the relay off: %q", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHelpHasTheInboundLogAndFilterListsTopics(t *testing.T) {
|
||||||
|
h, _ := inboundHandlers(t)
|
||||||
|
rec := send(h.HandleHelp, &globalPrincipal, "GET", "/help", nil, nil)
|
||||||
|
has(t, "help", rec.Body.String(),
|
||||||
|
`id="inbound-log"`, `id="filter-lists"`, `href="#inbound-log"`, `href="#filter-lists"`,
|
||||||
|
`With the inbound relay on there is a second number, for the Inbound log, with the same range.`)
|
||||||
|
// Both pages' help links land on them.
|
||||||
|
s := inboundLogStand{h: h, st: h.store, dir: filepath.Join(t.TempDir(), "antispam")}
|
||||||
|
h.lists = inbound.NewLists(h.store, s.dir)
|
||||||
|
has(t, "log", s.get(h.HandleInboundLog, "/inbound/log", globalPrincipal).Body.String(), `href="/help#inbound-log"`)
|
||||||
|
has(t, "lists", s.get(h.HandleFilterLists, "/inbound/filter-lists", globalPrincipal).Body.String(), `href="/help#filter-lists"`)
|
||||||
|
}
|
||||||
@@ -10,7 +10,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Server › Settings holds what is true of the whole instance, whoever is
|
// Server › Settings holds what is true of the whole instance, whoever is
|
||||||
// signed in: log retention and the read-only view of the rate limits. A
|
// signed in: log retention (the outbound log's, and the inbound log's while the
|
||||||
|
// inbound relay is on) and the read-only view of the rate limits. A
|
||||||
// user's own name, e-mail, password and DMARC default are on Account
|
// user's own name, e-mail, password and DMARC default are on Account
|
||||||
// (handlers_account.go). Before placing a setting here, ask whose it is.
|
// (handlers_account.go). Before placing a setting here, ask whose it is.
|
||||||
|
|
||||||
@@ -21,7 +22,7 @@ func (h *Handlers) HandleServerSettings(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
h.renderServerSettings(w, r, http.StatusOK, "", h.sendLogRetentionDays())
|
h.renderServerSettings(w, r, http.StatusOK, "", h.currentRetentions())
|
||||||
case http.MethodPost:
|
case http.MethodPost:
|
||||||
h.submitServerSettings(w, r)
|
h.submitServerSettings(w, r)
|
||||||
default:
|
default:
|
||||||
@@ -30,8 +31,26 @@ func (h *Handlers) HandleServerSettings(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handlers) renderServerSettings(w http.ResponseWriter, r *http.Request, status int, formErr string, formRetention int) {
|
// retentions are the two journal retentions the Settings form shows: the
|
||||||
page := view.NewSettings(h.shellMeta(r), strconv.Itoa(formRetention), view.FormatRate(h.l1Messages(), h.l1Window()))
|
// outbound log's, and the inbound log's while the inbound relay is on.
|
||||||
|
type retentions struct {
|
||||||
|
outbound, inbound int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) currentRetentions() retentions {
|
||||||
|
cur := retentions{outbound: h.sendLogRetentionDays()}
|
||||||
|
if h.cfg.InboundEnabled {
|
||||||
|
cur.inbound = h.inboundSpamLogRetentionDays()
|
||||||
|
}
|
||||||
|
return cur
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) renderServerSettings(w http.ResponseWriter, r *http.Request, status int, formErr string, form retentions) {
|
||||||
|
page := view.NewSettings(h.shellMeta(r), strconv.Itoa(form.outbound), view.FormatRate(h.l1Messages(), h.l1Window()))
|
||||||
|
// The inbound field exists while there is an inbound journal to keep.
|
||||||
|
if h.cfg.InboundEnabled {
|
||||||
|
page.WithInbound(strconv.Itoa(form.inbound))
|
||||||
|
}
|
||||||
flash := ""
|
flash := ""
|
||||||
if r.URL.Query().Has("saved") {
|
if r.URL.Query().Has("saved") {
|
||||||
flash = "Settings saved."
|
flash = "Settings saved."
|
||||||
@@ -41,7 +60,7 @@ func (h *Handlers) renderServerSettings(w http.ResponseWriter, r *http.Request,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handlers) submitServerSettings(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) submitServerSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
current := h.sendLogRetentionDays()
|
current := h.currentRetentions()
|
||||||
if err := r.ParseForm(); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
h.renderServerSettings(w, r, http.StatusBadRequest, "Invalid form submission.", current)
|
h.renderServerSettings(w, r, http.StatusBadRequest, "Invalid form submission.", current)
|
||||||
return
|
return
|
||||||
@@ -51,17 +70,40 @@ func (h *Handlers) submitServerSettings(w http.ResponseWriter, r *http.Request)
|
|||||||
h.renderServerSettings(w, r, http.StatusBadRequest, "Send log retention must be a whole number of days.", current)
|
h.renderServerSettings(w, r, http.StatusBadRequest, "Send log retention must be a whole number of days.", current)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
form := retentions{outbound: days, inbound: current.inbound}
|
||||||
if err := store.ValidateSendLogRetentionDays(days); err != nil {
|
if err := store.ValidateSendLogRetentionDays(days); err != nil {
|
||||||
h.renderServerSettings(w, r, http.StatusBadRequest, err.Error(), days)
|
h.renderServerSettings(w, r, http.StatusBadRequest, err.Error(), form)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if h.cfg.InboundEnabled {
|
||||||
|
inboundDays, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue("inbound_spam_log_retention_days")))
|
||||||
|
if err != nil {
|
||||||
|
h.renderServerSettings(w, r, http.StatusBadRequest, "Inbound log retention must be a whole number of days.", form)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
form.inbound = inboundDays
|
||||||
|
if err := store.ValidateInboundSpamLogRetentionDays(inboundDays); err != nil {
|
||||||
|
h.renderServerSettings(w, r, http.StatusBadRequest, err.Error(), form)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := h.store.SetSendLogRetentionDays(days); err != nil {
|
if err := h.store.SetSendLogRetentionDays(days); err != nil {
|
||||||
logf("panel: settings: set send-log retention failed: %v", err)
|
logf("panel: settings: set send-log retention failed: %v", err)
|
||||||
h.renderServerSettings(w, r, http.StatusInternalServerError,
|
h.renderServerSettings(w, r, http.StatusInternalServerError,
|
||||||
"Could not save send log retention. Please check the logs and try again.", days)
|
"Could not save send log retention. Please check the logs and try again.", form)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
logf("panel: instance settings updated (send-log retention: %d days)", days)
|
if h.cfg.InboundEnabled {
|
||||||
|
if err := h.store.SetInboundSpamLogRetentionDays(form.inbound); err != nil {
|
||||||
|
logf("panel: settings: set inbound-log retention failed: %v", err)
|
||||||
|
h.renderServerSettings(w, r, http.StatusInternalServerError,
|
||||||
|
"Could not save inbound log retention. Please check the logs and try again.", form)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logf("panel: instance settings updated (send-log retention: %d days, inbound-log retention: %d days)", days, form.inbound)
|
||||||
|
} else {
|
||||||
|
logf("panel: instance settings updated (send-log retention: %d days)", days)
|
||||||
|
}
|
||||||
http.Redirect(w, r, "/server/settings?saved=1", http.StatusSeeOther)
|
http.Redirect(w, r, "/server/settings?saved=1", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ type Settings struct {
|
|||||||
|
|
||||||
Retention Box
|
Retention Box
|
||||||
RetentionDays string
|
RetentionDays string
|
||||||
|
// Inbound is whether the inbound relay is on, which gives the retention box
|
||||||
|
// its second field; InboundRetentionDays is what that field shows.
|
||||||
|
Inbound bool
|
||||||
|
InboundRetentionDays string
|
||||||
|
|
||||||
RateLimits Box
|
RateLimits Box
|
||||||
RateFacts []Fact
|
RateFacts []Fact
|
||||||
@@ -86,6 +90,14 @@ func NewSettings(m Meta, retention, level1 string) *Settings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithInbound adds the retention of the Inbound log to the Log retention box.
|
||||||
|
// days is what the field shows, like retention in NewSettings. The field exists
|
||||||
|
// only while the inbound relay is on: with it off there is no journal to keep.
|
||||||
|
func (s *Settings) WithInbound(days string) *Settings {
|
||||||
|
s.Inbound, s.InboundRetentionDays = true, days
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
// WithResult shows the result of the last action, or its refusal, between the
|
// WithResult shows the result of the last action, or its refusal, between the
|
||||||
// head and the form.
|
// head and the form.
|
||||||
func (s *Settings) WithResult(flash, formErr string) *Settings {
|
func (s *Settings) WithResult(flash, formErr string) *Settings {
|
||||||
|
|||||||
@@ -111,12 +111,14 @@ type Tag struct {
|
|||||||
var statusClass = map[string]string{
|
var statusClass = map[string]string{
|
||||||
"ok": "tag is-success is-light", "published": "tag is-success is-light",
|
"ok": "tag is-success is-light", "published": "tag is-success is-light",
|
||||||
"running": "tag is-success is-light", "sent": "tag is-success is-light",
|
"running": "tag is-success is-light", "sent": "tag is-success is-light",
|
||||||
|
"accept": "tag is-success is-light",
|
||||||
|
|
||||||
"warn": "tag is-warning is-light", "deferred": "tag is-warning is-light",
|
"warn": "tag is-warning is-light", "deferred": "tag is-warning is-light",
|
||||||
"quiet": "tag is-warning is-light",
|
"quiet": "tag is-warning is-light", "tempfail": "tag is-warning is-light",
|
||||||
|
|
||||||
"error": "tag is-danger is-light", "fail": "tag is-danger is-light",
|
"error": "tag is-danger is-light", "fail": "tag is-danger is-light",
|
||||||
"mismatch": "tag is-danger is-light", "bounced": "tag is-danger is-light",
|
"mismatch": "tag is-danger is-light", "bounced": "tag is-danger is-light",
|
||||||
|
"reject": "tag is-danger is-light",
|
||||||
|
|
||||||
"quarantine": "tag is-primary is-light", "held": "tag is-primary is-light",
|
"quarantine": "tag is-primary is-light", "held": "tag is-primary is-light",
|
||||||
"global": "tag is-primary is-light",
|
"global": "tag is-primary is-light",
|
||||||
@@ -503,6 +505,9 @@ type Shell struct {
|
|||||||
ShowDMARC bool
|
ShowDMARC bool
|
||||||
ShowInbound bool
|
ShowInbound bool
|
||||||
ShowServer bool
|
ShowServer bool
|
||||||
|
// ShowFilterLists is the Filter lists entry of Inbound: the lists are the
|
||||||
|
// instance's, so only the global role has them.
|
||||||
|
ShowFilterLists bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Frame is what layout.html is executed with: the shell and the page's own
|
// Frame is what layout.html is executed with: the shell and the page's own
|
||||||
@@ -523,6 +528,8 @@ func (e *Engine) shell(m Meta, copyright, sourceURL string) Shell {
|
|||||||
ShowQueue: m.IsGlobal,
|
ShowQueue: m.IsGlobal,
|
||||||
ShowOutbound: m.IsGlobal || m.HasOutbound,
|
ShowOutbound: m.IsGlobal || m.HasOutbound,
|
||||||
ShowInbound: e.inboundEnabled && (m.IsGlobal || m.HasInbound),
|
ShowInbound: e.inboundEnabled && (m.IsGlobal || m.HasInbound),
|
||||||
|
|
||||||
|
ShowFilterLists: e.inboundEnabled && m.IsGlobal,
|
||||||
}
|
}
|
||||||
s.ShowDMARC = e.dmarcEnabled && s.ShowOutbound
|
s.ShowDMARC = e.dmarcEnabled && s.ShowOutbound
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
@@ -71,7 +71,8 @@ func TestStatusTagMapsEveryStatusOfTheKitTable(t *testing.T) {
|
|||||||
"ok": "tag is-success is-light", "published": "tag is-success is-light",
|
"ok": "tag is-success is-light", "published": "tag is-success is-light",
|
||||||
"running": "tag is-success is-light", "sent": "tag is-success is-light",
|
"running": "tag is-success is-light", "sent": "tag is-success is-light",
|
||||||
"warn": "tag is-warning is-light", "deferred": "tag is-warning is-light",
|
"warn": "tag is-warning is-light", "deferred": "tag is-warning is-light",
|
||||||
"quiet": "tag is-warning is-light",
|
"quiet": "tag is-warning is-light",
|
||||||
|
"accept": "tag is-success is-light", "tempfail": "tag is-warning is-light", "reject": "tag is-danger is-light",
|
||||||
"error": "tag is-danger is-light", "fail": "tag is-danger is-light",
|
"error": "tag is-danger is-light", "fail": "tag is-danger is-light",
|
||||||
"mismatch": "tag is-danger is-light", "bounced": "tag is-danger is-light",
|
"mismatch": "tag is-danger is-light", "bounced": "tag is-danger is-light",
|
||||||
"quarantine": "tag is-primary is-light", "global": "tag is-primary is-light",
|
"quarantine": "tag is-primary is-light", "global": "tag is-primary is-light",
|
||||||
@@ -413,7 +414,7 @@ func TestShellVisibilityFollowsRoleAndFlags(t *testing.T) {
|
|||||||
wantIn bool
|
wantIn bool
|
||||||
}{
|
}{
|
||||||
{"global, everything on", on, Meta{IsGlobal: true},
|
{"global, everything on", on, Meta{IsGlobal: true},
|
||||||
Shell{Home: "/overview", ShowOverview: true, ShowOutbound: true, ShowQueue: true, ShowDMARC: true, ShowInbound: true, ShowServer: true}, true},
|
Shell{Home: "/overview", ShowOverview: true, ShowOutbound: true, ShowQueue: true, ShowDMARC: true, ShowInbound: true, ShowServer: true, ShowFilterLists: true}, true},
|
||||||
{"global, inbound and DMARC off", off, Meta{IsGlobal: true},
|
{"global, inbound and DMARC off", off, Meta{IsGlobal: true},
|
||||||
Shell{Home: "/overview", ShowOverview: true, ShowOutbound: true, ShowQueue: true, ShowServer: true}, true},
|
Shell{Home: "/overview", ShowOverview: true, ShowOutbound: true, ShowQueue: true, ShowServer: true}, true},
|
||||||
{"domain admin with both reaches", on, Meta{HasOutbound: true, HasInbound: true},
|
{"domain admin with both reaches", on, Meta{HasOutbound: true, HasInbound: true},
|
||||||
@@ -446,19 +447,19 @@ func renderShell(t *testing.T, e *Engine, m Meta) string {
|
|||||||
return rec.Body.String()
|
return rec.Body.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// The menu has the 2.0 entries and none of the ones that are not in 2.0 (plan
|
// The menu has the entries that are built and none of the ones that are not (plan
|
||||||
// § Routes): no Inbound Log, Queue, Quarantine or Filter lists, no Preflight.
|
// § Routes): no Inbound Queue or Quarantine, no Preflight.
|
||||||
func TestMenuHoldsTheTwoDotZeroEntries(t *testing.T) {
|
func TestMenuHoldsTheTwoDotZeroEntries(t *testing.T) {
|
||||||
out := renderShell(t, kitEngine(t), Meta{Title: "t", User: "admin", IsGlobal: true})
|
out := renderShell(t, kitEngine(t), Meta{Title: "t", User: "admin", IsGlobal: true})
|
||||||
for _, href := range []string{
|
for _, href := range []string{
|
||||||
"/overview", "/outbound/domains", "/outbound/log", "/outbound/queue", "/outbound/dmarc",
|
"/overview", "/outbound/domains", "/outbound/log", "/outbound/queue", "/outbound/dmarc",
|
||||||
"/inbound/domains", "/server/health", "/server/log", "/server/backup", "/server/users", "/server/settings",
|
"/inbound/domains", "/inbound/log", "/inbound/filter-lists", "/server/health", "/server/log", "/server/backup", "/server/users", "/server/settings",
|
||||||
"/account", "/help", "/license",
|
"/account", "/help", "/license",
|
||||||
} {
|
} {
|
||||||
mustContain(t, out, `href="`+href+`"`)
|
mustContain(t, out, `href="`+href+`"`)
|
||||||
}
|
}
|
||||||
for _, gone := range []string{
|
for _, gone := range []string{
|
||||||
"/inbound/log", "/inbound/queue", "/inbound/quarantine", "/inbound/filter-lists", "/server/preflight",
|
"/inbound/queue", "/inbound/quarantine", "/server/preflight",
|
||||||
`href="/status"`, `href="/domains"`, `href="/users"`, `href="/settings"`,
|
`href="/status"`, `href="/domains"`, `href="/users"`, `href="/settings"`,
|
||||||
} {
|
} {
|
||||||
mustNotContain(t, out, gone)
|
mustNotContain(t, out, gone)
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ var pageFixtures = map[string]func() any{
|
|||||||
"in-domains": func() any { return inDomainsFixture() },
|
"in-domains": func() any { return inDomainsFixture() },
|
||||||
"in-domain": func() any { return inDomainFixture() },
|
"in-domain": func() any { return inDomainFixture() },
|
||||||
"in-domain-delete": func() any { return inDomainDeleteFixture() },
|
"in-domain-delete": func() any { return inDomainDeleteFixture() },
|
||||||
|
"in-log": func() any { return inLogFixture() },
|
||||||
|
"in-filter-lists": func() any { return inFilterListsFixture() },
|
||||||
|
|
||||||
"out-log": func() any { return outLogFixture() },
|
"out-log": func() any { return outLogFixture() },
|
||||||
"out-message": func() any { return outMessageFixture() },
|
"out-message": func() any { return outMessageFixture() },
|
||||||
@@ -131,7 +133,7 @@ func accountFixture() *Account {
|
|||||||
// settingsFixture is the mockup's Settings: thirty days of log, a limit of 600
|
// settingsFixture is the mockup's Settings: thirty days of log, a limit of 600
|
||||||
// messages an hour.
|
// messages an hour.
|
||||||
func settingsFixture() *Settings {
|
func settingsFixture() *Settings {
|
||||||
return NewSettings(admin(), "30", FormatRate(600, 3600))
|
return NewSettings(admin(), "30", FormatRate(600, 3600)).WithInbound("90")
|
||||||
}
|
}
|
||||||
|
|
||||||
// outDomainsFixture is the mockup's Outbound domains: three domains, the first
|
// outDomainsFixture is the mockup's Outbound domains: three domains, the first
|
||||||
@@ -411,3 +413,47 @@ func userFixture() *UserForm {
|
|||||||
func userDeleteFixture() *UserDelete {
|
func userDeleteFixture() *UserDelete {
|
||||||
return NewUserDelete(admin(), UserDeleteInput{ID: 3, Username: "acme-ops", AllOutbound: true, Inbound: []string{"acme.io"}})
|
return NewUserDelete(admin(), UserDeleteInput{ID: 3, Username: "acme-ops", AllOutbound: true, Inbound: []string{"acme.io"}})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inLogFixture is the mockup's Inbound log: six rows of a week's mail, as the
|
||||||
|
// global role sees them - an accepted message scored as spam and one let through
|
||||||
|
// by an Allow entry, refusals by Postfix and by the filter, and a tempfail.
|
||||||
|
func inLogFixture() *InLog {
|
||||||
|
p := NewInLog(admin(), 90, []string{"lists.example.org", "acme.io"},
|
||||||
|
InLogFilter{From: "2026-09-14", To: "2026-09-21"})
|
||||||
|
row := func(clock, from, client, to, subject, decision, engine, detail string) InLogRow {
|
||||||
|
at, err := time.Parse("2006-01-02 15:04:05", "2026-09-21 "+clock)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return p.NewRow(InLogRowInput{At: at, From: from, Client: client, To: to, Subject: subject,
|
||||||
|
Decision: decision, Engine: engine, Detail: detail})
|
||||||
|
}
|
||||||
|
return p.WithRows([]InLogRow{
|
||||||
|
row("14:00:18", "t.berg@uni.example", "192.0.2.77", "dev@lists.example.org", "Re: [dev] patch series v3",
|
||||||
|
"accept", "rspamd", "no action, score 0.40 / 15.00"),
|
||||||
|
row("13:59:02", "invoice@partner-billing.example", "203.0.113.140", "owner@lists.example.org", "Outstanding invoice — action required",
|
||||||
|
"accept", "rspamd", "add header, score 9.80 / 15.00; PHISHING(5.00) FORGED_SENDER(3.00) R_SPF_FAIL(1.00)"),
|
||||||
|
row("13:57:41", "promo@bulk.example", "198.51.100.90", "sales@lists.example.org", "",
|
||||||
|
"reject", "postfix", "550 5.1.1 <sales@lists.example.org>: Recipient address rejected: User unknown in relay recipient table"),
|
||||||
|
row("13:51:30", "billing@partner.example", "198.51.100.12", "owner@lists.example.org", "",
|
||||||
|
"reject", "rspamd", "5.7.1 Spam message rejected"),
|
||||||
|
row("13:44:02", "hr@partner.example", "192.0.2.31", "jobs@acme.io", "",
|
||||||
|
"tempfail", "rspamd", "4.7.1 Try again later"),
|
||||||
|
row("13:30:55", "noreply@github.example", "192.0.2.200", "security@lists.example.org", "[advisory] new report",
|
||||||
|
"accept", "rspamd", "no action, score -98.10 / 15.00; SELFPOST_ALLOW_FROM(-100.00) R_DKIM_ALLOW(-0.20)"),
|
||||||
|
}, 1, 38)
|
||||||
|
}
|
||||||
|
|
||||||
|
// inFilterListsFixture is the mockup's Filter lists: three senders denied, two
|
||||||
|
// allowed, and a filter that reads them.
|
||||||
|
func inFilterListsFixture() *FilterLists {
|
||||||
|
day := func(m time.Month, d int) time.Time { return time.Date(2026, m, d, 0, 0, 0, 0, time.UTC) }
|
||||||
|
return NewFilterLists(admin(), FilterListsInput{FilterOn: true, LastWritten: time.Date(2026, 9, 11, 8, 40, 0, 0, time.UTC)}).
|
||||||
|
WithEntries([]FilterEntryInput{
|
||||||
|
{ID: 1, List: "deny", Kind: "domain", Value: "bulk.example", Note: "newsletter nobody subscribed to", Added: day(9, 2)},
|
||||||
|
{ID: 2, List: "deny", Kind: "ip", Value: "198.51.100.0/24", Note: "dictionary attack on lists.example.org", Added: day(8, 19)},
|
||||||
|
{ID: 3, List: "deny", Kind: "address", Value: "win@lottery.example", Added: day(7, 30)},
|
||||||
|
{ID: 4, List: "allow", Kind: "domain", Value: "partner.example", Note: "invoices were scored as spam", Added: day(9, 11)},
|
||||||
|
{ID: 5, List: "allow", Kind: "address", Value: "noreply@github.example", Note: "security advisories", Added: day(6, 4)},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ const (
|
|||||||
HelpUpstream = "upstream"
|
HelpUpstream = "upstream"
|
||||||
HelpRecipients = "recipients"
|
HelpRecipients = "recipients"
|
||||||
HelpFilter = "filter"
|
HelpFilter = "filter"
|
||||||
|
// HelpInboundLog and HelpFilterLists: the plain "log" is the Outbound log's.
|
||||||
|
HelpInboundLog = "inbound-log"
|
||||||
|
HelpFilterLists = "filter-lists"
|
||||||
// Server
|
// Server
|
||||||
HelpBackup = "backup"
|
HelpBackup = "backup"
|
||||||
HelpUsers = "users"
|
HelpUsers = "users"
|
||||||
@@ -109,6 +112,8 @@ func NewHelp(m Meta, inbound bool) *Help {
|
|||||||
{Label: "Upstream", Icon: "ti-arrow-forward-up", Href: "#" + HelpUpstream},
|
{Label: "Upstream", Icon: "ti-arrow-forward-up", Href: "#" + HelpUpstream},
|
||||||
{Label: "Valid recipients", Icon: "ti-address-book", Href: "#" + HelpRecipients},
|
{Label: "Valid recipients", Icon: "ti-address-book", Href: "#" + HelpRecipients},
|
||||||
{Label: "Spam filter", Icon: "ti-shield-check", Href: "#" + HelpFilter},
|
{Label: "Spam filter", Icon: "ti-shield-check", Href: "#" + HelpFilter},
|
||||||
|
{Label: "Inbound log", Icon: "ti-list-details", Href: "#" + HelpInboundLog},
|
||||||
|
{Label: "Filter lists", Icon: "ti-filter", Href: "#" + HelpFilterLists},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if m.IsGlobal {
|
if m.IsGlobal {
|
||||||
@@ -184,6 +189,24 @@ var inboundTopics = []HelpTopic{
|
|||||||
Rich("The spam filter belongs to the server, not to a domain. The server's administrator attaches it in the server's ", Code(".env"),
|
Rich("The spam filter belongs to the server, not to a domain. The server's administrator attaches it in the server's ", Code(".env"),
|
||||||
", and from then on every inbound domain is screened by it before mail is handed on; the box shows on or off and has nothing to switch. Off means mail goes to the upstream unfiltered. While the filter is down, mail is either accepted unfiltered (the default) or deferred, so the sending server tries again later, as the server's setting says; the box states which."),
|
", and from then on every inbound domain is screened by it before mail is handed on; the box shows on or off and has nothing to switch. Off means mail goes to the upstream unfiltered. While the filter is down, mail is either accepted unfiltered (the default) or deferred, so the sending server tries again later, as the server's setting says; the box states which."),
|
||||||
}},
|
}},
|
||||||
|
{ID: HelpInboundLog, Title: "Inbound log", Body: []Text{
|
||||||
|
Rich("One row per recipient of a message offered on port 25 for an inbound domain, with what was decided: ",
|
||||||
|
TagOf("accept", ""), " — handed on to the upstream; ", TagOf("reject", ""), " — refused for good; ",
|
||||||
|
TagOf("tempfail", ""), " — the sender was asked to come back later; ", TagOf("quarantine", ""), " — held by the filter. ",
|
||||||
|
Em("Why"), " names who decided. ", Code("rspamd"), " is the spam filter, with its action, the score against the reject threshold and the heaviest symbols; ",
|
||||||
|
Code("postfix"), " is the relay's own rule — an unknown recipient, a sender domain that does not exist — with its reply; ",
|
||||||
|
Code("selfpost"), " means nobody judged the message: it was relayed with no filter attached. Accept says what this server did, not what the upstream did with the message afterwards.",
|
||||||
|
Br(), Br(),
|
||||||
|
"Accepted mail is recorded as it passes; refused mail is read from ", Code("mail.log"),
|
||||||
|
", so its row has no subject, and a message refused after its text was sent is one row however many recipients it had. A refusal to relay for a domain that is not an inbound one is every scanner on the internet and is not recorded. Rows are kept for the number of days set in Settings, and never more than 10 000."),
|
||||||
|
}},
|
||||||
|
{ID: HelpFilterLists, Title: "Filter lists", Body: []Text{
|
||||||
|
Rich("Two lists for the whole server that correct the spam filter by hand. An entry is a sender address, a sender domain — that domain, not its subdomains — or a client address or network in CIDR form. A sender on the Deny list is refused outright. A sender on the Allow list is given a weight no spam score outweighs, so the filter lets it through. When entries of both lists match one message, ",
|
||||||
|
Strong("Deny wins"), ".",
|
||||||
|
Br(), Br(),
|
||||||
|
"SelfPost does not apply the lists itself: it writes them as files under ", Code("/data/antispam/"),
|
||||||
|
", and rspamd rereads a file within seconds of a change. With the spam filter off the lists are kept and can be edited, but nothing reads them. The sender matched is the envelope sender — the one in the log — not the From line a mail program shows."),
|
||||||
|
}},
|
||||||
}
|
}
|
||||||
|
|
||||||
var serverTopics = []HelpTopic{
|
var serverTopics = []HelpTopic{
|
||||||
@@ -208,7 +231,7 @@ var serverTopics = []HelpTopic{
|
|||||||
}},
|
}},
|
||||||
{ID: HelpSettings, Title: "Settings", Body: []Text{
|
{ID: HelpSettings, Title: "Settings", Body: []Text{
|
||||||
Rich("Settings holds what is true of the whole instance. Log retention is how many days the Outbound log keeps its rows, from 7 to 365 and 90 by default; a sweep every six hours deletes older ones, and it is the main driver of how fast ",
|
Rich("Settings holds what is true of the whole instance. Log retention is how many days the Outbound log keeps its rows, from 7 to 365 and 90 by default; a sweep every six hours deletes older ones, and it is the main driver of how fast ",
|
||||||
Code("/data"), " grows. It does not change the rotation of ", Code("mail.log"),
|
Code("/data"), " grows. With the inbound relay on there is a second number, for the Inbound log, with the same range. It does not change the rotation of ", Code("mail.log"),
|
||||||
", which is daily with about two weeks kept. The sending rate limit shown under it is level 1, the per-client-IP ceiling set in ",
|
", which is daily with about two weeks kept. The sending rate limit shown under it is level 1, the per-client-IP ceiling set in ",
|
||||||
Code(".env"), ": changing it needs a restart, and no level-2 limit can exceed it."),
|
Code(".env"), ": changing it needs a restart, and no level-2 limit can exceed it."),
|
||||||
}},
|
}},
|
||||||
|
|||||||
@@ -164,3 +164,30 @@ func TestHelpLinksStayInsideWhatTheirViewerIsShown(t *testing.T) {
|
|||||||
landsOn("DMARC hub", hub.Recent.Help)
|
landsOn("DMARC hub", hub.Recent.Help)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The Inbound log and Filter lists have their topics, after the spam filter's, in
|
||||||
|
// the menu and on the page, for everyone who is shown Inbound; the Settings topic
|
||||||
|
// mentions the second retention.
|
||||||
|
func TestHelpHasTheInboundLogAndFilterListsTopics(t *testing.T) {
|
||||||
|
out := renderSignedIn(t, "help", NewHelp(admin(), true))
|
||||||
|
pageHas(t, "Help", out,
|
||||||
|
`<li><a href="#inbound-log"><i class="ti ti-list-details"></i>Inbound log</a></li>`,
|
||||||
|
`<li><a href="#filter-lists"><i class="ti ti-filter"></i>Filter lists</a></li>`,
|
||||||
|
`<div class="sp-help-topic" id="inbound-log"><h3>Inbound log</h3>`,
|
||||||
|
`<span class="tag is-primary is-light">quarantine</span> — held by the filter. <em>Why</em> names who decided.`,
|
||||||
|
`a message refused after its text was sent is one row however many recipients it had.`,
|
||||||
|
`and never more than 10 000.`,
|
||||||
|
`<div class="sp-help-topic" id="filter-lists"><h3>Filter lists</h3>`,
|
||||||
|
`<strong>Deny wins</strong>.`, `files under <code>/data/antispam/</code>`,
|
||||||
|
`With the inbound relay on there is a second number, for the Inbound log, with the same range.`)
|
||||||
|
for _, v := range helpViewers {
|
||||||
|
out := renderSignedIn(t, "help", NewHelp(v.meta, v.inbound))
|
||||||
|
shown := strings.Contains(out, `id="inbound-log"`)
|
||||||
|
if want := v.inbound && (v.meta.IsGlobal || v.meta.HasInbound); shown != want {
|
||||||
|
t.Errorf("%s: inbound log topic shown = %v, want %v", v.name, shown, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if HelpInboundLog != "inbound-log" || HelpFilterLists != "filter-lists" {
|
||||||
|
t.Errorf("anchors %q and %q", HelpInboundLog, HelpFilterLists)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func TestInDomainShowsTheMockupsBoxes(t *testing.T) {
|
|||||||
`<h2>Spam filter</h2>`, `href="#filter"`, `href="/inbound/domains/1/delete"`, `Delete domain`,
|
`<h2>Spam filter</h2>`, `href="#filter"`, `href="/inbound/domains/1/delete"`, `Delete domain`,
|
||||||
)
|
)
|
||||||
// What has no source in this build is not drawn.
|
// What has no source in this build is not drawn.
|
||||||
pageLacks(t, "Inbound domain", out, `rspamd`, `quarantine`, `Quarantine`, `Log for this domain`, `/inbound/log`, `/inbound/quarantine`, `Allow and deny`)
|
pageLacks(t, "Inbound domain", out, `rspamd`, `quarantine`, `Quarantine`, `Log for this domain`, `/inbound/quarantine`, `Allow and deny`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The Spam filter box states what the server is set up with, and what of it the
|
// The Spam filter box states what the server is set up with, and what of it the
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package view
|
||||||
|
|
||||||
|
// The data of Inbound › Filter lists (templates/in_filter_lists.html, mockup
|
||||||
|
// in-filter-lists.html): the allow and deny lists that correct the external spam
|
||||||
|
// filter by hand. The lists are instance-wide, so the page is the global role's.
|
||||||
|
//
|
||||||
|
// A page is a typed struct that embeds Meta, built by a constructor and filled
|
||||||
|
// by With… methods; the handler reads the lists, the view decides what is said
|
||||||
|
// and how it is worded.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const inboundFilterLists = "/inbound/filter-lists"
|
||||||
|
|
||||||
|
// FilterListsHref is the address of the page with the add form filled in: list
|
||||||
|
// is "allow" or "deny", kind "address", "domain" or "ip". The Inbound log links
|
||||||
|
// its rows here.
|
||||||
|
func FilterListsHref(list, kind, value string) string {
|
||||||
|
return inboundFilterLists + "?list=" + url.QueryEscape(list) + "&kind=" + url.QueryEscape(kind) + "&value=" + url.QueryEscape(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilterLists is the data of the page: the form that adds an entry, the two
|
||||||
|
// lists side by side and what happens to them after that.
|
||||||
|
type FilterLists struct {
|
||||||
|
Meta
|
||||||
|
Head Head
|
||||||
|
Flash *Flash
|
||||||
|
|
||||||
|
Add Box
|
||||||
|
Action string
|
||||||
|
ListOptions []Option
|
||||||
|
KindOptions []Option
|
||||||
|
// List, Kind, Value and Note are what the form shows: blank, prefilled from
|
||||||
|
// the Inbound log, or what was typed into a form that was refused.
|
||||||
|
List string
|
||||||
|
Kind string
|
||||||
|
Value string
|
||||||
|
Note string
|
||||||
|
|
||||||
|
Deny FilterList
|
||||||
|
Allow FilterList
|
||||||
|
|
||||||
|
Where Box
|
||||||
|
WhereFacts []Fact
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilterList is one of the two boxes of entries.
|
||||||
|
type FilterList struct {
|
||||||
|
Box Box
|
||||||
|
Rows []FilterRow
|
||||||
|
Empty EmptyState
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilterRow is one entry of a list: the value with the kind of entry as a tag,
|
||||||
|
// the operator's note, the day it was added and the way to remove it.
|
||||||
|
type FilterRow struct {
|
||||||
|
Value string
|
||||||
|
Kind Tag
|
||||||
|
Note string
|
||||||
|
Added string
|
||||||
|
Remove RowAction
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilterEntryInput is what the page knows of one entry of a list.
|
||||||
|
type FilterEntryInput struct {
|
||||||
|
ID int64
|
||||||
|
List string
|
||||||
|
Kind string
|
||||||
|
Value string
|
||||||
|
Note string
|
||||||
|
Added time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilterListsInput is the state of the filter the lists are written for: whether
|
||||||
|
// a filter is attached at all, and when the map files it reads were last written.
|
||||||
|
type FilterListsInput struct {
|
||||||
|
FilterOn bool
|
||||||
|
LastWritten time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFilterLists builds the page. The form starts on the Deny list for a sender
|
||||||
|
// address, the commonest correction.
|
||||||
|
func NewFilterLists(m Meta, in FilterListsInput) *FilterLists {
|
||||||
|
m.Title, m.Section, m.Page = "Filter lists", "inbound", "filter-lists"
|
||||||
|
p := &FilterLists{
|
||||||
|
Meta: m,
|
||||||
|
Head: Head{
|
||||||
|
Kicker: "Inbound · global role",
|
||||||
|
Title: "Filter lists",
|
||||||
|
Lead: Plain("Correct the spam filter by hand: senders it must always let through, and senders it must always turn away. One pair of lists for the whole instance."),
|
||||||
|
},
|
||||||
|
Add: Box{No: "01", Title: "Add an entry", Help: topicLink(HelpFilterLists, "How the lists are applied")},
|
||||||
|
Action: inboundFilterLists,
|
||||||
|
ListOptions: []Option{
|
||||||
|
{Value: "deny", Label: "Deny — always turn away"},
|
||||||
|
{Value: "allow", Label: "Allow — always let through"},
|
||||||
|
},
|
||||||
|
KindOptions: []Option{
|
||||||
|
{Value: "address", Label: "a sender address"},
|
||||||
|
{Value: "domain", Label: "a sender domain"},
|
||||||
|
{Value: "ip", Label: "a client address or network"},
|
||||||
|
},
|
||||||
|
List: "deny",
|
||||||
|
Kind: "address",
|
||||||
|
|
||||||
|
Deny: FilterList{Box: Box{No: "02", Title: "Deny"}, Empty: EmptyState{Icon: "ti-filter", Text: Plain("No sender is turned away by hand.")}},
|
||||||
|
Allow: FilterList{Box: Box{No: "03", Title: "Allow"}, Empty: EmptyState{Icon: "ti-filter", Text: Plain("No sender is let through by hand.")}},
|
||||||
|
Where: Box{No: "04", Title: "Where the lists go"},
|
||||||
|
}
|
||||||
|
p.Deny.Box.End = Plain(plural(0, "entry", "entries") + " · wins over Allow")
|
||||||
|
p.Allow.Box.End = Plain(plural(0, "entry", "entries"))
|
||||||
|
p.setWhere(in)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// setWhere writes box 04: the tag that says whether anything reads the lists,
|
||||||
|
// and the four facts about where they go. With no filter attached the lists are
|
||||||
|
// still kept and editable, and the box says so instead of promising an effect.
|
||||||
|
func (p *FilterLists) setWhere(in FilterListsInput) {
|
||||||
|
applied := Plain("rspamd, from map files it rereads within seconds of a change")
|
||||||
|
p.Where.End = Rich(TagOf("ok", "applied"))
|
||||||
|
if !in.FilterOn {
|
||||||
|
p.Where.End = Rich(TagOf("off", "not applied"))
|
||||||
|
applied = Rich("Nothing — the spam filter is off. The lists are kept and can be edited; nothing reads them until ",
|
||||||
|
Code("INBOUND_ANTISPAM_MILTER"), " is set.")
|
||||||
|
}
|
||||||
|
written := "never"
|
||||||
|
if !in.LastWritten.IsZero() {
|
||||||
|
written = in.LastWritten.UTC().Format("2006-01-02 15:04") + " UTC"
|
||||||
|
}
|
||||||
|
p.WhereFacts = []Fact{
|
||||||
|
{Label: "Applied by", Value: applied},
|
||||||
|
{Label: "Files", Value: Rich(Mono("/data/antispam/"), " — six, one for each list and kind of entry")},
|
||||||
|
{Label: "Last written", Value: Plain(written)},
|
||||||
|
{Label: "When both lists match", Value: Plain("Deny wins, whichever entries they are.")},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithEntries sets the two tables, each counted in its box head.
|
||||||
|
func (p *FilterLists) WithEntries(entries []FilterEntryInput) *FilterLists {
|
||||||
|
p.Deny.Rows, p.Allow.Rows = nil, nil
|
||||||
|
for _, e := range entries {
|
||||||
|
row := FilterRow{
|
||||||
|
Value: e.Value, Kind: Tag{Label: e.Kind}, Note: e.Note, Added: e.Added.UTC().Format("2006-01-02"),
|
||||||
|
Remove: RowAction{Label: "Remove", Post: inboundFilterLists + "/" + strconv.FormatInt(e.ID, 10) + "/delete", Danger: true},
|
||||||
|
}
|
||||||
|
switch e.List {
|
||||||
|
case "deny":
|
||||||
|
p.Deny.Rows = append(p.Deny.Rows, row)
|
||||||
|
case "allow":
|
||||||
|
p.Allow.Rows = append(p.Allow.Rows, row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.Deny.Box.End = Plain(plural(int64(len(p.Deny.Rows)), "entry", "entries") + " · wins over Allow")
|
||||||
|
p.Allow.Box.End = Plain(plural(int64(len(p.Allow.Rows)), "entry", "entries"))
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithForm sets what the add form shows. The handler passes only what it has
|
||||||
|
// checked to be a list and a kind the form offers, or what was submitted to a
|
||||||
|
// form it is showing again.
|
||||||
|
func (p *FilterLists) WithForm(list, kind, value, note string) *FilterLists {
|
||||||
|
p.List, p.Kind, p.Value, p.Note = list, kind, value, note
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithResult shows the result of the last action, or the refusal of the add
|
||||||
|
// form, between the head and the first box.
|
||||||
|
func (p *FilterLists) WithResult(flash, formErr string) *FilterLists {
|
||||||
|
p.Flash = resultFlash(flash, formErr)
|
||||||
|
return p
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package view
|
||||||
|
|
||||||
|
// The data of the Inbound log (templates/in_log.html, mockup in-log.html): what
|
||||||
|
// port 25 decided about each message offered for an inbound domain, one row per
|
||||||
|
// recipient. Unlike the Outbound log it does not poll: a decision does not change
|
||||||
|
// after it is made, and the page is read to find one row, not watched.
|
||||||
|
//
|
||||||
|
// A page is a typed struct that embeds Meta, built by a constructor and filled
|
||||||
|
// by With… methods; the handler reads the journal, the view decides what is
|
||||||
|
// said and how it is worded.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const inboundLog = "/inbound/log"
|
||||||
|
|
||||||
|
// InLogFilter is what the filters of the Inbound log are set to, as the strings
|
||||||
|
// the form sends: a domain name, a decision and two days (2006-01-02). Empty
|
||||||
|
// means no narrowing.
|
||||||
|
type InLogFilter struct {
|
||||||
|
Domain string
|
||||||
|
Decision string
|
||||||
|
From string
|
||||||
|
To string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f InLogFilter) active() bool { return f != InLogFilter{} }
|
||||||
|
|
||||||
|
// 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 (f InLogFilter) query(page int) string {
|
||||||
|
q := url.Values{}
|
||||||
|
for name, v := range map[string]string{"domain": f.Domain, "decision": f.Decision, "from": f.From, "to": f.To} {
|
||||||
|
if v != "" {
|
||||||
|
q.Set(name, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if page > 1 {
|
||||||
|
q.Set("p", strconv.Itoa(page))
|
||||||
|
}
|
||||||
|
if len(q) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "?" + q.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// InLogHref is the address of a page of the Inbound log.
|
||||||
|
func InLogHref(f InLogFilter, page int) string { return inboundLog + f.query(page) }
|
||||||
|
|
||||||
|
// InLog is the data of the Inbound log: the decisions about mail for the inbound
|
||||||
|
// domains the viewer reaches, filtered by domain, decision and day, a page at a
|
||||||
|
// time.
|
||||||
|
type InLog struct {
|
||||||
|
Meta
|
||||||
|
Head Head
|
||||||
|
|
||||||
|
Messages Box
|
||||||
|
Rows []InLogRow
|
||||||
|
Empty EmptyState
|
||||||
|
Foot Foot
|
||||||
|
|
||||||
|
filter InLogFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
// InLogRow is one recipient of one message: when it was decided, who the mail
|
||||||
|
// was from (and the address of the server that offered it) and for, its subject,
|
||||||
|
// the decision as a tag, who decided and why, and — for the global role, who
|
||||||
|
// alone edits the filter lists — the way to correct the decision.
|
||||||
|
type InLogRow struct {
|
||||||
|
Time string
|
||||||
|
From string
|
||||||
|
Client string
|
||||||
|
To string
|
||||||
|
Subject string
|
||||||
|
Decision Tag
|
||||||
|
Why string
|
||||||
|
Action *Anchor
|
||||||
|
}
|
||||||
|
|
||||||
|
// InLogRowInput is what the journal holds about one row. Subject, Detail and
|
||||||
|
// From may be empty: refused mail is read from mail.log, which has no subject,
|
||||||
|
// and a filter that said nothing has no detail.
|
||||||
|
type InLogRowInput struct {
|
||||||
|
At time.Time
|
||||||
|
From string
|
||||||
|
Client string
|
||||||
|
To string
|
||||||
|
Subject string
|
||||||
|
Decision string
|
||||||
|
Engine string
|
||||||
|
Detail string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewInLog builds the log for a viewer. domains are the choices of the domain
|
||||||
|
// filter (only the inbound domains the viewer reaches) and filter is what is in
|
||||||
|
// force. retentionDays is how long rows are kept; the global role, who can
|
||||||
|
// change it and who edits the lists, is read from m.
|
||||||
|
func NewInLog(m Meta, retentionDays int, domains []string, filter InLogFilter) *InLog {
|
||||||
|
m.Title, m.Section, m.Page = "Inbound log", "inbound", "log"
|
||||||
|
lead := Rich("What port 25 decided about each message, one row per recipient. Accepted mail is recorded as it passes, refused mail is read from ",
|
||||||
|
Code("mail.log"), "; the filter's verdict appears when the spam filter is on. Rows older than "+plural(int64(retentionDays), "day", "days")+" are deleted")
|
||||||
|
if m.IsGlobal {
|
||||||
|
lead = append(lead, Inline{Text: " — change that in "}, Link("/server/settings", "Settings"))
|
||||||
|
}
|
||||||
|
lead = append(lead, Inline{Text: "."})
|
||||||
|
|
||||||
|
domainOptions := []Option{{Value: "", Label: "All domains"}}
|
||||||
|
for _, d := range domains {
|
||||||
|
domainOptions = append(domainOptions, Option{Value: d, Label: d})
|
||||||
|
}
|
||||||
|
decisionOptions := []Option{{Value: "", Label: "Any decision"}}
|
||||||
|
for _, d := range []string{"accept", "reject", "tempfail", "quarantine"} {
|
||||||
|
decisionOptions = append(decisionOptions, Option{Value: d, Label: d})
|
||||||
|
}
|
||||||
|
p := &InLog{
|
||||||
|
Meta: m,
|
||||||
|
Head: Head{Kicker: "Inbound", Title: "Inbound log", Lead: lead},
|
||||||
|
Messages: Box{No: "01", Title: "Messages", Help: topicLink(HelpInboundLog, "What the decisions mean"), Filter: &Filter{
|
||||||
|
Action: inboundLog,
|
||||||
|
Selects: []Select{
|
||||||
|
{Name: "domain", Label: "Domain", Options: domainOptions, Selected: filter.Domain},
|
||||||
|
{Name: "decision", Label: "Decision", Options: decisionOptions, Selected: filter.Decision},
|
||||||
|
},
|
||||||
|
Dates: []DateInput{
|
||||||
|
{Name: "from", Label: "From date", Value: filter.From},
|
||||||
|
{Name: "to", Label: "To date", Value: filter.To},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
Empty: EmptyState{Icon: "ti-list-details", Text: Plain("No messages logged yet.")},
|
||||||
|
filter: filter,
|
||||||
|
}
|
||||||
|
if filter.active() {
|
||||||
|
p.Empty.Text = Plain("No messages match these filters.")
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRow words one row of the journal for the viewer of the page.
|
||||||
|
func (p *InLog) NewRow(in InLogRowInput) InLogRow {
|
||||||
|
why := in.Engine
|
||||||
|
if in.Detail != "" {
|
||||||
|
why += " · " + in.Detail
|
||||||
|
}
|
||||||
|
r := InLogRow{
|
||||||
|
Time: FormatLogTime(in.At), From: orDash(in.From), Client: in.Client, To: in.To, Subject: in.Subject,
|
||||||
|
Decision: Tag{Status: in.Decision}, Why: why,
|
||||||
|
}
|
||||||
|
if p.IsGlobal {
|
||||||
|
r.Action = senderAction(in.Decision, in.Engine, in.From)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// senderAction is the way to correct a decision from its row, if there is one:
|
||||||
|
// a message that got through is the one to deny its sender for, a message the
|
||||||
|
// filter turned away or put off is the one to allow its sender for, and a
|
||||||
|
// refusal by Postfix's own policy (an unknown recipient, say) can only be
|
||||||
|
// answered by denying — allowing the sender would not change what Postfix
|
||||||
|
// thinks of the recipient. A row with no sender has nobody to list.
|
||||||
|
func senderAction(decision, engine, from string) *Anchor {
|
||||||
|
if from == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
list := ""
|
||||||
|
switch {
|
||||||
|
case decision == "accept":
|
||||||
|
list = "deny"
|
||||||
|
case engine == "rspamd":
|
||||||
|
list = "allow"
|
||||||
|
case decision == "reject" && engine == "postfix":
|
||||||
|
list = "deny"
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
label := "Deny sender"
|
||||||
|
if list == "allow" {
|
||||||
|
label = "Allow sender"
|
||||||
|
}
|
||||||
|
return &Anchor{Href: FilterListsHref(list, "address", from), Label: label}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithRows sets the table for page of last, and the foot under it: where the
|
||||||
|
// viewer is, and the way to the newer and older rows. For the global role the
|
||||||
|
// foot also says where the sender links lead.
|
||||||
|
func (p *InLog) WithRows(rows []InLogRow, page, last int) *InLog {
|
||||||
|
p.Rows = rows
|
||||||
|
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(InLogHref(p.filter, page-1), "← Newer"))
|
||||||
|
}
|
||||||
|
p.Foot.Text = text
|
||||||
|
if page < last {
|
||||||
|
p.Foot.Link = &Anchor{Href: InLogHref(p.filter, page+1), Label: "Older →"}
|
||||||
|
}
|
||||||
|
if p.IsGlobal {
|
||||||
|
p.Foot.End = Rich("Allow / Deny sender opens ", Link(inboundFilterLists, "Filter lists"), " with the entry filled in")
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
package view
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- The log
|
||||||
|
|
||||||
|
// The log is the whole of what the journal holds about the viewer's inbound mail:
|
||||||
|
// the filters offer what the viewer reaches, the head says how long rows live and
|
||||||
|
// where to change it, and the global role is given the way to correct a decision.
|
||||||
|
func TestInLogShowsTheRowsAndTheirFilters(t *testing.T) {
|
||||||
|
out := renderSignedIn(t, "in-log", inLogFixture())
|
||||||
|
pageHas(t, "Inbound log", out,
|
||||||
|
`<h1 class="title is-3">Inbound log</h1>`,
|
||||||
|
`refused mail is read from <code>mail.log</code>; the filter's verdict appears when the spam filter is on. Rows older than 90 days are deleted — change that in <a href="/server/settings">Settings</a>.`,
|
||||||
|
`<form class="sp-end" method="get" action="/inbound/log">`,
|
||||||
|
`<select name="domain" aria-label="Domain"><option value="" selected>All domains</option><option value="lists.example.org">lists.example.org</option><option value="acme.io">acme.io</option></select>`,
|
||||||
|
`<select name="decision" aria-label="Decision"><option value="" selected>Any decision</option><option value="accept">accept</option><option value="reject">reject</option><option value="tempfail">tempfail</option><option value="quarantine">quarantine</option></select>`,
|
||||||
|
`<input class="input" type="date" name="from" value="2026-09-14" aria-label="From date">`,
|
||||||
|
`<input class="input" type="date" name="to" value="2026-09-21" aria-label="To date">`,
|
||||||
|
`href="/help#inbound-log" title="What the decisions mean"`,
|
||||||
|
`<th>Time, UTC</th><th>From · client</th><th>To</th><th>Subject</th><th>Decision</th><th>Why</th>`,
|
||||||
|
`09-21 14:00:18`,
|
||||||
|
`t.berg@<wbr>uni.example<br><span class="sp-mono sp-muted sp-small">192.0.2.77</span>`,
|
||||||
|
`dev@<wbr>lists.example.org`, `title="Re: [dev] patch series v3"`,
|
||||||
|
`<td class="sp-clip sp-muted">—</td>`,
|
||||||
|
`<span class="tag is-success is-light">accept</span>`, `<span class="tag is-danger is-light">reject</span>`,
|
||||||
|
`<span class="tag is-warning is-light">tempfail</span>`,
|
||||||
|
`rspamd · no action, score 0.40 / 15.00`,
|
||||||
|
`postfix · 550 5.1.1 <sales@lists.example.org>: Recipient address rejected`,
|
||||||
|
`Page 1 of 38`, `<a href="/inbound/log?from=2026-09-14&p=2&to=2026-09-21">Older →</a>`,
|
||||||
|
`Allow / Deny sender opens <a href="/inbound/filter-lists">Filter lists</a> with the entry filled in`,
|
||||||
|
)
|
||||||
|
pageLacks(t, "Inbound log", out, `← Newer`, `data-poll`, `hx-get`)
|
||||||
|
|
||||||
|
// Without the global role there is nowhere to change the retention, and
|
||||||
|
// nowhere to send a sender.
|
||||||
|
domain := NewInLog(Meta{User: "ops", HasInbound: true}, 7, []string{"acme.io"}, InLogFilter{})
|
||||||
|
out = renderSignedIn(t, "in-log", domain.WithRows([]InLogRow{domain.NewRow(InLogRowInput{
|
||||||
|
At: time.Date(2026, 9, 21, 14, 0, 0, 0, time.UTC), From: "a@example.net", To: "b@acme.io",
|
||||||
|
Decision: "accept", Engine: "rspamd"})}, 1, 1))
|
||||||
|
pageHas(t, "Inbound log", out, `Rows older than 7 days are deleted.`, `rspamd</td>`)
|
||||||
|
pageLacks(t, "Inbound log", out, `/server/settings`, `/inbound/filter-lists`, `Deny sender`, `Allow sender`, `Allow / Deny`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The lead's day count reads right for one day too, though the setting's range
|
||||||
|
// starts at seven: the word follows the number, not the other way round.
|
||||||
|
func TestInLogLeadCountsDays(t *testing.T) {
|
||||||
|
out := renderSignedIn(t, "in-log", NewInLog(Meta{User: "ops", HasInbound: true}, 1, nil, InLogFilter{}))
|
||||||
|
pageHas(t, "Inbound log", out, `Rows older than 1 day are deleted.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The filter in force is the one selected, and the paging and the empty state
|
||||||
|
// carry and name it.
|
||||||
|
func TestInLogCarriesItsFilters(t *testing.T) {
|
||||||
|
f := InLogFilter{Domain: "acme.io", Decision: "reject", From: "2026-09-01", To: "2026-09-30"}
|
||||||
|
p := NewInLog(admin(), 90, []string{"lists.example.org", "acme.io"}, f)
|
||||||
|
p.WithRows([]InLogRow{p.NewRow(InLogRowInput{At: time.Date(2026, 9, 2, 1, 2, 3, 0, time.UTC),
|
||||||
|
From: "a@example.net", To: "b@acme.io", Decision: "reject", Engine: "postfix"})}, 3, 5)
|
||||||
|
out := renderSignedIn(t, "in-log", p)
|
||||||
|
pageHas(t, "Inbound log", out,
|
||||||
|
`<option value="acme.io" selected>acme.io</option>`, `<option value="reject" selected>reject</option>`,
|
||||||
|
`name="from" value="2026-09-01"`, `name="to" value="2026-09-30"`,
|
||||||
|
`Page 3 of 5 · <a href="/inbound/log?decision=reject&domain=acme.io&from=2026-09-01&p=2&to=2026-09-30">← Newer</a>`,
|
||||||
|
`<a href="/inbound/log?decision=reject&domain=acme.io&from=2026-09-01&p=4&to=2026-09-30">Older →</a>`,
|
||||||
|
)
|
||||||
|
if got := InLogHref(InLogFilter{}, 1); got != "/inbound/log" {
|
||||||
|
t.Errorf("the first unfiltered page is %q", got)
|
||||||
|
}
|
||||||
|
if got := InLogHref(InLogFilter{Domain: "a b&c"}, 2); got != "/inbound/log?domain=a+b%26c&p=2" {
|
||||||
|
t.Errorf("a filter is not encoded: %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
out = renderSignedIn(t, "in-log", NewInLog(admin(), 90, nil, f).WithRows(nil, 1, 1))
|
||||||
|
pageHas(t, "Inbound log", out, `No messages match these filters.`)
|
||||||
|
out = renderSignedIn(t, "in-log", NewInLog(admin(), 90, nil, InLogFilter{}).WithRows(nil, 1, 1))
|
||||||
|
pageHas(t, "Inbound log", out, `No messages logged yet.`)
|
||||||
|
pageLacks(t, "Inbound log", out, `<table`, `Page 1`, `sp-box-foot`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The last page has nothing older.
|
||||||
|
func TestInLogLastPage(t *testing.T) {
|
||||||
|
p := NewInLog(admin(), 90, nil, InLogFilter{})
|
||||||
|
row := p.NewRow(InLogRowInput{At: time.Now(), From: "a@example.net", To: "b@acme.io", Decision: "accept", Engine: "selfpost"})
|
||||||
|
out := renderSignedIn(t, "in-log", p.WithRows([]InLogRow{row}, 2, 2))
|
||||||
|
pageHas(t, "Inbound log", out, `Page 2 of 2`, `← Newer</a>`)
|
||||||
|
pageLacks(t, "Inbound log", out, `Older →`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A row says who decided, and what they said when they said something; a message
|
||||||
|
// refused before its sender was known has no sender to show or to list.
|
||||||
|
func TestInLogRowWording(t *testing.T) {
|
||||||
|
p := NewInLog(admin(), 90, nil, InLogFilter{})
|
||||||
|
at := time.Date(2026, 9, 21, 14, 0, 18, 0, time.UTC)
|
||||||
|
r := p.NewRow(InLogRowInput{At: at, From: "a@example.net", To: "b@acme.io", Decision: "accept", Engine: "selfpost"})
|
||||||
|
if r.Why != "selfpost" || r.Time != "09-21 14:00:18" || r.Client != "" {
|
||||||
|
t.Errorf("row = %+v", r)
|
||||||
|
}
|
||||||
|
r = p.NewRow(InLogRowInput{At: at, To: "b@acme.io", Decision: "reject", Engine: "postfix", Detail: "550 5.1.1 no"})
|
||||||
|
if r.Why != "postfix · 550 5.1.1 no" || r.From != "—" || r.Action != nil {
|
||||||
|
t.Errorf("row = %+v", r)
|
||||||
|
}
|
||||||
|
out := renderSignedIn(t, "in-log", p.WithRows([]InLogRow{r}, 1, 1))
|
||||||
|
pageHas(t, "Inbound log", out, `<td>—</td>`)
|
||||||
|
pageLacks(t, "Inbound log", out, `<br><span class="sp-mono sp-muted sp-small"></span>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which decisions lead to which list, for the global role: the sender of mail
|
||||||
|
// that got through can be denied, the sender of mail the filter turned away or
|
||||||
|
// put off can be allowed, and a refusal that is Postfix's own can only be denied.
|
||||||
|
func TestInLogSenderAction(t *testing.T) {
|
||||||
|
for _, c := range []struct {
|
||||||
|
decision, engine, from, label, href string
|
||||||
|
}{
|
||||||
|
{"accept", "rspamd", "a@example.net", "Deny sender", "/inbound/filter-lists?list=deny&kind=address&value=a%40example.net"},
|
||||||
|
{"accept", "selfpost", "a@example.net", "Deny sender", "/inbound/filter-lists?list=deny&kind=address&value=a%40example.net"},
|
||||||
|
{"reject", "rspamd", "a+b@example.net", "Allow sender", "/inbound/filter-lists?list=allow&kind=address&value=a%2Bb%40example.net"},
|
||||||
|
{"tempfail", "rspamd", "a@example.net", "Allow sender", "/inbound/filter-lists?list=allow&kind=address&value=a%40example.net"},
|
||||||
|
{"quarantine", "rspamd", "a@example.net", "Allow sender", "/inbound/filter-lists?list=allow&kind=address&value=a%40example.net"},
|
||||||
|
{"reject", "postfix", "a@example.net", "Deny sender", "/inbound/filter-lists?list=deny&kind=address&value=a%40example.net"},
|
||||||
|
{"tempfail", "postfix", "a@example.net", "", ""},
|
||||||
|
{"reject", "selfpost", "a@example.net", "", ""},
|
||||||
|
{"accept", "rspamd", "", "", ""},
|
||||||
|
{"reject", "rspamd", "", "", ""},
|
||||||
|
} {
|
||||||
|
got := senderAction(c.decision, c.engine, c.from)
|
||||||
|
switch {
|
||||||
|
case c.label == "" && got != nil:
|
||||||
|
t.Errorf("%s by %s from %q is offered %+v", c.decision, c.engine, c.from, *got)
|
||||||
|
case c.label != "" && (got == nil || got.Label != c.label || got.Href != c.href):
|
||||||
|
t.Errorf("%s by %s from %q is offered %+v, want %s -> %s", c.decision, c.engine, c.from, got, c.label, c.href)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The address in the link is data: it cannot leave the attribute.
|
||||||
|
p := NewInLog(admin(), 90, nil, InLogFilter{})
|
||||||
|
row := p.NewRow(InLogRowInput{At: time.Now(), From: `"><script>1</script>@x`, To: "b@acme.io", Decision: "accept", Engine: "rspamd"})
|
||||||
|
out := renderSignedIn(t, "in-log", p.WithRows([]InLogRow{row}, 1, 1))
|
||||||
|
pageLacks(t, "Inbound log", out, `<script>1`)
|
||||||
|
pageHas(t, "Inbound log", out, `value=%22%3E%3Cscript%3E1%3C%2Fscript%3E%40x`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// What a sender wrote is text on the page: an address, a subject or a reply from
|
||||||
|
// a server is never markup.
|
||||||
|
func TestInLogEscapesWhatSendersWrote(t *testing.T) {
|
||||||
|
p := NewInLog(admin(), 90, nil, InLogFilter{})
|
||||||
|
row := p.NewRow(InLogRowInput{At: time.Now(), From: `<img src=x onerror=alert(1)>@e.example`, Client: `<b>1</b>`,
|
||||||
|
To: `<script>alert(2)</script>@acme.io`, Subject: `<script>alert(3)</script> "quoted"`,
|
||||||
|
Decision: "reject", Engine: "rspamd", Detail: `<i>5.7.1</i>`})
|
||||||
|
out := renderSignedIn(t, "in-log", p.WithRows([]InLogRow{row}, 1, 1))
|
||||||
|
pageLacks(t, "Inbound log", out, `<script>alert`, `<img src=x`, `<b>1</b>`, `<i>5.7.1</i>`)
|
||||||
|
pageHas(t, "Inbound log", out, `<script>alert(3)</script>`, `<i>5.7.1</i>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Filter lists
|
||||||
|
|
||||||
|
// The page is the add form, the two lists with their counts, and where the lists
|
||||||
|
// go - drawn as the mockup draws it.
|
||||||
|
func TestFilterListsShowsTheFormTheListsAndWhereTheyGo(t *testing.T) {
|
||||||
|
out := renderSignedIn(t, "in-filter-lists", inFilterListsFixture())
|
||||||
|
pageHas(t, "Filter lists", out,
|
||||||
|
`<p class="sp-kicker">Inbound · global role</p>`, `<h1 class="title is-3">Filter lists</h1>`,
|
||||||
|
`<form class="sp-form" method="post" action="/inbound/filter-lists">`,
|
||||||
|
`<select id="list" name="list_type">`, `<option value="deny" selected>Deny — always turn away</option>`,
|
||||||
|
`<option value="allow">Allow — always let through</option>`,
|
||||||
|
`<select id="etype" name="entry_type">`, `<option value="address" selected>a sender address</option>`,
|
||||||
|
`<option value="domain">a sender domain</option>`, `<option value="ip">a client address or network</option>`,
|
||||||
|
`<input class="input sp-mono" id="value" name="value" value="" placeholder="promo@bulk.example"`,
|
||||||
|
`<input class="input" id="note" name="note" value=""`, `Add entry`,
|
||||||
|
`href="/help#filter-lists" title="How the lists are applied"`,
|
||||||
|
`<h2>Deny</h2><div class="sp-end">3 entries · wins over Allow</div>`,
|
||||||
|
`<h2>Allow</h2><div class="sp-end">2 entries</div>`,
|
||||||
|
`<td class="sp-mono sp-small">bulk.example <span class="tag is-light">domain</span></td><td class="sp-muted">newsletter nobody subscribed to</td><td class="sp-muted sp-nowrap sp-small">2026-09-02</td>`,
|
||||||
|
`198.51.100.0/24 <span class="tag is-light">ip</span>`,
|
||||||
|
`win@lottery.example <span class="tag is-light">address</span></td><td class="sp-muted">—</td>`,
|
||||||
|
`<form method="post" action="/inbound/filter-lists/3/delete"><button type="submit" class="has-text-danger">Remove</button></form>`,
|
||||||
|
`<h2>Where the lists go</h2><div class="sp-end"><span class="tag is-success is-light">applied</span></div>`,
|
||||||
|
`rspamd, from map files it rereads within seconds of a change`,
|
||||||
|
`<span class="sp-mono">/data/antispam/</span> — six, one for each list and kind of entry`,
|
||||||
|
`2026-09-11 08:40 UTC`, `Deny wins, whichever entries they are.`,
|
||||||
|
)
|
||||||
|
if got := strings.Count(out, `<table`); got != 2 {
|
||||||
|
t.Errorf("%d tables, want the two lists", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With no filter attached the lists are still the operator's to edit, and the
|
||||||
|
// box says nothing reads them; a list with no entries says so in its box.
|
||||||
|
func TestFilterListsOffAndEmpty(t *testing.T) {
|
||||||
|
p := NewFilterLists(admin(), FilterListsInput{}).WithEntries([]FilterEntryInput{
|
||||||
|
{ID: 9, List: "allow", Kind: "ip", Value: "192.0.2.1", Added: time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)},
|
||||||
|
})
|
||||||
|
out := renderSignedIn(t, "in-filter-lists", p)
|
||||||
|
pageHas(t, "Filter lists", out,
|
||||||
|
`<span class="tag is-light">not applied</span>`,
|
||||||
|
`Nothing — the spam filter is off. The lists are kept and can be edited; nothing reads them until <code>INBOUND_ANTISPAM_MILTER</code> is set.`,
|
||||||
|
`<dt>Last written</dt><dd>never</dd>`,
|
||||||
|
`<h2>Deny</h2><div class="sp-end">0 entries · wins over Allow</div>`,
|
||||||
|
`<h2>Allow</h2><div class="sp-end">1 entry</div>`,
|
||||||
|
`No sender is turned away by hand.`,
|
||||||
|
)
|
||||||
|
pageLacks(t, "Filter lists", out, `rspamd, from map files`, `is-success is-light">applied`, `No sender is let through by hand.`)
|
||||||
|
if got := strings.Count(out, `<table`); got != 1 {
|
||||||
|
t.Errorf("%d tables, want the one list that has entries", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The form shows what it is told to: prefilled from the log, or what was typed
|
||||||
|
// into a form that was refused, with the refusal between the head and the form.
|
||||||
|
func TestFilterListsFormAndRefusal(t *testing.T) {
|
||||||
|
p := inFilterListsFixture().
|
||||||
|
WithForm("allow", "domain", `x"><script>1</script>`, `a "note"`).
|
||||||
|
WithResult("", "That entry is already on the list.")
|
||||||
|
out := renderSignedIn(t, "in-filter-lists", p)
|
||||||
|
pageHas(t, "Filter lists", out,
|
||||||
|
`<option value="allow" selected>Allow — always let through</option>`, `<option value="domain" selected>a sender domain</option>`,
|
||||||
|
`value="x"><script>1</script>"`, `value="a "note""`,
|
||||||
|
`<div class="notification is-danger is-light">`, `That entry is already on the list.`)
|
||||||
|
pageLacks(t, "Filter lists", out, `<script>1`, `<option value="deny" selected>`)
|
||||||
|
if strings.Index(out, `class="notification`) < strings.Index(out, `class="sp-head"`) ||
|
||||||
|
strings.Index(out, `class="notification`) > strings.Index(out, `<form class="sp-form"`) {
|
||||||
|
t.Error("the refusal is not between the head and the form")
|
||||||
|
}
|
||||||
|
|
||||||
|
out = renderSignedIn(t, "in-filter-lists", inFilterListsFixture().WithResult("Entry added.", ""))
|
||||||
|
pageHas(t, "Filter lists", out, `<div class="notification is-success is-light">`, `Entry added.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// What a person typed into a list is text on the page.
|
||||||
|
func TestFilterListsEscapesTheEntries(t *testing.T) {
|
||||||
|
p := NewFilterLists(admin(), FilterListsInput{FilterOn: true}).WithEntries([]FilterEntryInput{
|
||||||
|
{ID: 1, List: "deny", Kind: "address", Value: `<img src=x>@a.example`, Note: `<script>alert(1)</script>`, Added: time.Now()},
|
||||||
|
})
|
||||||
|
out := renderSignedIn(t, "in-filter-lists", p)
|
||||||
|
pageLacks(t, "Filter lists", out, `<img src=x>`, `<script>alert`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The menu leads to both pages, the lists for the global role only.
|
||||||
|
func TestInboundMenuHasLogAndFilterLists(t *testing.T) {
|
||||||
|
out := renderSignedIn(t, "in-log", inLogFixture())
|
||||||
|
pageHas(t, "Inbound log", out,
|
||||||
|
`<a class="navbar-item is-active" href="/inbound/log"><i class="ti ti-list-details"></i>Log</a>`,
|
||||||
|
`<a class="navbar-item" href="/inbound/filter-lists"><i class="ti ti-filter"></i>Filter lists</a>`,
|
||||||
|
`<a href="/inbound/log" aria-current="page"><i class="ti ti-list-details"></i>Log</a><a href="/inbound/filter-lists"><i class="ti ti-filter"></i>Filter lists</a>`)
|
||||||
|
out = renderSignedIn(t, "in-filter-lists", inFilterListsFixture())
|
||||||
|
pageHas(t, "Filter lists", out,
|
||||||
|
`<a class="navbar-item is-active" href="/inbound/filter-lists">`, `<a href="/inbound/filter-lists" aria-current="page">`)
|
||||||
|
|
||||||
|
domain := NewInLog(Meta{User: "ops", HasInbound: true}, 90, nil, InLogFilter{}).WithRows(nil, 1, 1)
|
||||||
|
out = renderSignedIn(t, "in-log", domain)
|
||||||
|
pageHas(t, "Inbound log", out, `<a class="navbar-item is-active" href="/inbound/log">`, `<a href="/inbound/log" aria-current="page">`)
|
||||||
|
pageLacks(t, "Inbound log", out, `/inbound/filter-lists`)
|
||||||
|
}
|
||||||
@@ -92,9 +92,9 @@ func KitPage() *Kit {
|
|||||||
|
|
||||||
StatusTag: Box{No: "03", Title: "status_tag", End: Plain("One mapping, one function")},
|
StatusTag: Box{No: "03", Title: "status_tag", End: Plain("One mapping, one function")},
|
||||||
StatusRows: []KitStatusRow{
|
StatusRows: []KitStatusRow{
|
||||||
{"ok, published, running, sent", Tag{Status: "ok"}},
|
{"ok, published, running, sent, accept", Tag{Status: "ok"}},
|
||||||
{"warn, deferred, quiet", Tag{Status: "warn"}},
|
{"warn, deferred, quiet, tempfail", Tag{Status: "warn"}},
|
||||||
{"error, fail, mismatch, bounced", Tag{Status: "fail"}},
|
{"error, fail, mismatch, bounced, reject", Tag{Status: "fail"}},
|
||||||
{"unknown, off, neutral counts", Tag{Status: "off"}},
|
{"unknown, off, neutral counts", Tag{Status: "off"}},
|
||||||
{"held: quarantine · role: global", Tag{Status: "quarantine"}},
|
{"held: quarantine · role: global", Tag{Status: "quarantine"}},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -402,3 +402,23 @@ func TestOverviewFragmentIsTheBoxAndTheHeadOutOfBand(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// With the inbound relay on the retention box has a second field, for the Inbound
|
||||||
|
// log; with it off the field is not there, since there is no journal to keep.
|
||||||
|
func TestSettingsHasTheInboundRetentionOnlyWithTheRelay(t *testing.T) {
|
||||||
|
out := renderSignedIn(t, "settings", settingsFixture())
|
||||||
|
pageHas(t, "Settings", out,
|
||||||
|
`for="inbound_spam_log_retention_days">Keep inbound log rows, days</label>`,
|
||||||
|
`name="inbound_spam_log_retention_days" type="number" min="7" max="365" step="1" value="90" required`,
|
||||||
|
`The inbound log also never holds more than 10 000 rows: refused mail is logged at the rate strangers knock on port 25.`,
|
||||||
|
`name="send_log_retention_days"`)
|
||||||
|
if got := strings.Count(out, `<form class="sp-form"`); got != 1 {
|
||||||
|
t.Errorf("Settings has %d forms, want 1", got)
|
||||||
|
}
|
||||||
|
pageLacks(t, "Settings", renderSignedIn(t, "settings", NewSettings(admin(), "30", "600 / h")),
|
||||||
|
`inbound_spam_log_retention_days`, `Keep inbound log rows`, `10 000 rows`)
|
||||||
|
|
||||||
|
// A refused value comes back as typed.
|
||||||
|
out = renderSignedIn(t, "settings", NewSettings(admin(), "30", "600 / h").WithInbound("3").WithResult("", "Inbound log retention must be between 7 and 365."))
|
||||||
|
pageHas(t, "Settings", out, `value="3"`, `between 7 and 365`)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
{{/* Inbound > Filter lists (mockup in-filter-lists.html): the allow and deny
|
||||||
|
lists that correct the spam filter by hand, for the whole instance, and
|
||||||
|
where they go. Global role only. The form adds an entry; each row of a list
|
||||||
|
removes its own. */}}
|
||||||
|
{{define "content"}}
|
||||||
|
{{template "page_head" .Head}}
|
||||||
|
{{if .Flash}}{{template "flash" .Flash}}{{end}}
|
||||||
|
|
||||||
|
{{template "box_open" .Add}}
|
||||||
|
{{template "body_open"}}
|
||||||
|
<form class="sp-form" method="post" action="/inbound/filter-lists">
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column is-3">
|
||||||
|
<div class="field"><label class="label" for="list">List</label>
|
||||||
|
<div class="control"><div class="select is-fullwidth"><select id="list" name="list_type">{{$list := .List}}{{range .ListOptions}}
|
||||||
|
<option value="{{.Value}}"{{if eq .Value $list}} selected{{end}}>{{.Label}}</option>{{end}}
|
||||||
|
</select></div></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="column is-3">
|
||||||
|
<div class="field"><label class="label" for="etype">Entry is</label>
|
||||||
|
<div class="control"><div class="select is-fullwidth"><select id="etype" name="entry_type">{{$kind := .Kind}}{{range .KindOptions}}
|
||||||
|
<option value="{{.Value}}"{{if eq .Value $kind}} selected{{end}}>{{.Label}}</option>{{end}}
|
||||||
|
</select></div></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="column is-6">
|
||||||
|
<div class="field"><label class="label" for="value">Value</label>
|
||||||
|
<div class="control"><input class="input sp-mono" id="value" name="value" value="{{.Value}}" placeholder="promo@bulk.example" autocomplete="off" autocapitalize="none" spellcheck="false" required></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field"><label class="label" for="note">Note</label>
|
||||||
|
<div class="control"><input class="input" id="note" name="note" value="{{.Note}}" placeholder="Why — for whoever reads this list next year"></div></div>
|
||||||
|
<button type="submit" class="button is-primary"><i class="ti ti-plus"></i>Add entry</button>
|
||||||
|
</form>
|
||||||
|
{{template "body_close"}}
|
||||||
|
{{template "box_close"}}
|
||||||
|
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column is-6">
|
||||||
|
{{template "filter_list" .Deny}}
|
||||||
|
</div>
|
||||||
|
<div class="column is-6">
|
||||||
|
{{template "filter_list" .Allow}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{template "box_open" .Where}}
|
||||||
|
{{template "body_open"}}
|
||||||
|
{{template "facts" .WhereFacts}}
|
||||||
|
{{template "body_close"}}
|
||||||
|
{{template "box_close"}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{/* filter_list is one of the two boxes of entries (input: FilterList): the
|
||||||
|
table, or what is absent when the list is empty. */}}
|
||||||
|
{{define "filter_list"}}{{template "box_open" .Box}}
|
||||||
|
{{if .Rows}}<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||||
|
<thead><tr><th>Value</th><th>Note</th><th>Added</th><th></th></tr></thead>
|
||||||
|
<tbody>{{range .Rows}}
|
||||||
|
<tr><td class="sp-mono sp-small">{{.Value}} {{template "tag" .Kind}}</td>{{if .Note}}<td class="sp-muted">{{.Note}}</td>{{else}}<td class="sp-muted">—</td>{{end}}<td class="sp-muted sp-nowrap sp-small">{{.Added}}</td><td class="sp-actions">{{template "row_action" .Remove}}</td></tr>{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table></div>
|
||||||
|
{{else}}{{template "empty_state" .Empty}}
|
||||||
|
{{end}}{{template "box_close"}}{{end}}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{{/* Inbound log (mockup in-log.html): what port 25 decided about each message
|
||||||
|
offered for an inbound domain, one row per recipient, newest first,
|
||||||
|
filtered by domain, decision and day. It does not poll. The last cell is
|
||||||
|
the way to correct a decision in Filter lists, and only the global role,
|
||||||
|
who alone edits the lists, is given it. */}}
|
||||||
|
{{define "content"}}
|
||||||
|
{{template "page_head" .Head}}
|
||||||
|
|
||||||
|
{{template "box_open" .Messages}}
|
||||||
|
{{if .Rows}}
|
||||||
|
<div class="table-container"><table class="table is-fullwidth is-hoverable">
|
||||||
|
<thead><tr><th>Time, UTC</th><th>From · client</th><th>To</th><th>Subject</th><th>Decision</th><th>Why</th><th></th></tr></thead>
|
||||||
|
<tbody>{{range .Rows}}
|
||||||
|
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">{{.Time}}</td><td>{{wbr_at .From}}{{if .Client}}<br><span class="sp-mono sp-muted sp-small">{{.Client}}</span>{{end}}</td><td>{{wbr_at .To}}</td>{{if .Subject}}<td class="sp-clip" title="{{.Subject}}">{{.Subject}}</td>{{else}}<td class="sp-clip sp-muted">—</td>{{end}}<td>{{template "tag" .Decision}}</td><td class="sp-muted sp-small">{{.Why}}</td><td class="sp-actions">{{if .Action}}<a href="{{.Action.Href}}">{{.Action.Label}}</a>{{end}}</td></tr>{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table></div>
|
||||||
|
{{template "box_foot" .Foot}}
|
||||||
|
{{else}}
|
||||||
|
{{template "empty_state" .Empty}}
|
||||||
|
{{end}}
|
||||||
|
{{template "box_close"}}
|
||||||
|
{{end}}
|
||||||
@@ -104,7 +104,9 @@
|
|||||||
<a class="navbar-link" href="/inbound/domains">Inbound</a>
|
<a class="navbar-link" href="/inbound/domains">Inbound</a>
|
||||||
<div class="navbar-dropdown">
|
<div class="navbar-dropdown">
|
||||||
<a class="navbar-item{{if and (eq .Section "inbound") (eq .Page "domains")}} is-active{{end}}" href="/inbound/domains"><i class="ti ti-world-download"></i>Domains</a>
|
<a class="navbar-item{{if and (eq .Section "inbound") (eq .Page "domains")}} is-active{{end}}" href="/inbound/domains"><i class="ti ti-world-download"></i>Domains</a>
|
||||||
</div>
|
<a class="navbar-item{{if and (eq .Section "inbound") (eq .Page "log")}} is-active{{end}}" href="/inbound/log"><i class="ti ti-list-details"></i>Log</a>
|
||||||
|
{{if .ShowFilterLists}} <a class="navbar-item{{if and (eq .Section "inbound") (eq .Page "filter-lists")}} is-active{{end}}" href="/inbound/filter-lists"><i class="ti ti-filter"></i>Filter lists</a>
|
||||||
|
{{end}} </div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}{{if .ShowServer}} <div class="navbar-item has-dropdown is-hoverable{{if eq .Section "server"}} sp-current{{end}}">
|
{{end}}{{if .ShowServer}} <div class="navbar-item has-dropdown is-hoverable{{if eq .Section "server"}} sp-current{{end}}">
|
||||||
<a class="navbar-link" href="/server/health">Server</a>
|
<a class="navbar-link" href="/server/health">Server</a>
|
||||||
@@ -143,6 +145,6 @@
|
|||||||
pages of the current group, the current one marked. A page outside every
|
pages of the current group, the current one marked. A page outside every
|
||||||
group (the user menu, the kit page's neighbours) has none. */}}
|
group (the user menu, the kit page's neighbours) has none. */}}
|
||||||
{{define "subnav"}}{{if and .ShowOutbound (eq .Section "outbound")}}<div class="sp-subnav"><div class="container"><a href="/outbound/domains"{{if eq .Page "domains"}} aria-current="page"{{end}}><i class="ti ti-world-upload"></i>Domains</a><a href="/outbound/log"{{if eq .Page "log"}} aria-current="page"{{end}}><i class="ti ti-list-details"></i>Log</a>{{if .ShowQueue}}<a href="/outbound/queue"{{if eq .Page "queue"}} aria-current="page"{{end}}><i class="ti ti-stack-2"></i>Queue</a>{{end}}{{if .ShowDMARC}}<a href="/outbound/dmarc"{{if eq .Page "dmarc"}} aria-current="page"{{end}}><i class="ti ti-report-analytics"></i>DMARC reports</a>{{end}}</div></div>
|
{{define "subnav"}}{{if and .ShowOutbound (eq .Section "outbound")}}<div class="sp-subnav"><div class="container"><a href="/outbound/domains"{{if eq .Page "domains"}} aria-current="page"{{end}}><i class="ti ti-world-upload"></i>Domains</a><a href="/outbound/log"{{if eq .Page "log"}} aria-current="page"{{end}}><i class="ti ti-list-details"></i>Log</a>{{if .ShowQueue}}<a href="/outbound/queue"{{if eq .Page "queue"}} aria-current="page"{{end}}><i class="ti ti-stack-2"></i>Queue</a>{{end}}{{if .ShowDMARC}}<a href="/outbound/dmarc"{{if eq .Page "dmarc"}} aria-current="page"{{end}}><i class="ti ti-report-analytics"></i>DMARC reports</a>{{end}}</div></div>
|
||||||
{{else if and .ShowInbound (eq .Section "inbound")}}<div class="sp-subnav"><div class="container"><a href="/inbound/domains"{{if eq .Page "domains"}} aria-current="page"{{end}}><i class="ti ti-world-download"></i>Domains</a></div></div>
|
{{else if and .ShowInbound (eq .Section "inbound")}}<div class="sp-subnav"><div class="container"><a href="/inbound/domains"{{if eq .Page "domains"}} aria-current="page"{{end}}><i class="ti ti-world-download"></i>Domains</a><a href="/inbound/log"{{if eq .Page "log"}} aria-current="page"{{end}}><i class="ti ti-list-details"></i>Log</a>{{if .ShowFilterLists}}<a href="/inbound/filter-lists"{{if eq .Page "filter-lists"}} aria-current="page"{{end}}><i class="ti ti-filter"></i>Filter lists</a>{{end}}</div></div>
|
||||||
{{else if and .ShowServer (eq .Section "server")}}<div class="sp-subnav"><div class="container"><a href="/server/health"{{if eq .Page "health"}} aria-current="page"{{end}}><i class="ti ti-heartbeat"></i>Health</a><a href="/server/log"{{if eq .Page "log"}} aria-current="page"{{end}}><i class="ti ti-file-text"></i>System log</a><a href="/server/backup"{{if eq .Page "backup"}} aria-current="page"{{end}}><i class="ti ti-archive"></i>Backup</a><a href="/server/users"{{if eq .Page "users"}} aria-current="page"{{end}}><i class="ti ti-users"></i>Users</a><a href="/server/settings"{{if eq .Page "settings"}} aria-current="page"{{end}}><i class="ti ti-settings"></i>Settings</a></div></div>
|
{{else if and .ShowServer (eq .Section "server")}}<div class="sp-subnav"><div class="container"><a href="/server/health"{{if eq .Page "health"}} aria-current="page"{{end}}><i class="ti ti-heartbeat"></i>Health</a><a href="/server/log"{{if eq .Page "log"}} aria-current="page"{{end}}><i class="ti ti-file-text"></i>System log</a><a href="/server/backup"{{if eq .Page "backup"}} aria-current="page"{{end}}><i class="ti ti-archive"></i>Backup</a><a href="/server/users"{{if eq .Page "users"}} aria-current="page"{{end}}><i class="ti ti-users"></i>Users</a><a href="/server/settings"{{if eq .Page "settings"}} aria-current="page"{{end}}><i class="ti ti-settings"></i>Settings</a></div></div>
|
||||||
{{end}}{{end}}
|
{{end}}{{end}}
|
||||||
|
|||||||
@@ -13,6 +13,10 @@
|
|||||||
<div class="field"><label class="label" for="send_log_retention_days">Keep outbound log rows, days</label>
|
<div class="field"><label class="label" for="send_log_retention_days">Keep outbound log rows, days</label>
|
||||||
<div class="control"><input class="input" id="send_log_retention_days" name="send_log_retention_days" type="number" min="7" max="365" step="1" value="{{.RetentionDays}}" required></div>
|
<div class="control"><input class="input" id="send_log_retention_days" name="send_log_retention_days" type="number" min="7" max="365" step="1" value="{{.RetentionDays}}" required></div>
|
||||||
<p class="help">The main driver of <code>/data</code> growth. Does not affect <code>mail.log</code> rotation (fourteen daily files).</p></div>
|
<p class="help">The main driver of <code>/data</code> growth. Does not affect <code>mail.log</code> rotation (fourteen daily files).</p></div>
|
||||||
|
{{if .Inbound}}<div class="field"><label class="label" for="inbound_spam_log_retention_days">Keep inbound log rows, days</label>
|
||||||
|
<div class="control"><input class="input" id="inbound_spam_log_retention_days" name="inbound_spam_log_retention_days" type="number" min="7" max="365" step="1" value="{{.InboundRetentionDays}}" required></div>
|
||||||
|
<p class="help">The inbound log also never holds more than 10 000 rows: refused mail is logged at the rate strangers knock on port 25.</p></div>
|
||||||
|
{{end}}
|
||||||
{{template "body_close"}}
|
{{template "body_close"}}
|
||||||
{{template "box_close"}}
|
{{template "box_close"}}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ var pageFiles = map[string][]string{
|
|||||||
"in-domains": {"templates/in_domains.html"},
|
"in-domains": {"templates/in_domains.html"},
|
||||||
"in-domain": {"templates/in_domain.html"},
|
"in-domain": {"templates/in_domain.html"},
|
||||||
"in-domain-delete": {"templates/in_domain_delete.html"},
|
"in-domain-delete": {"templates/in_domain_delete.html"},
|
||||||
|
"in-log": {"templates/in_log.html"},
|
||||||
|
"in-filter-lists": {"templates/in_filter_lists.html"},
|
||||||
"out-log": {"templates/out_log.html", "templates/out_log_rows.html"},
|
"out-log": {"templates/out_log.html", "templates/out_log_rows.html"},
|
||||||
"out-message": {"templates/out_message.html"},
|
"out-message": {"templates/out_message.html"},
|
||||||
"dmarc": {"templates/dmarc.html"},
|
"dmarc": {"templates/dmarc.html"},
|
||||||
|
|||||||
+14
-2
@@ -97,6 +97,9 @@ type Config struct {
|
|||||||
DMARCEnabled bool
|
DMARCEnabled bool
|
||||||
// SendLogRetentionEnvDefault is SEND_LOG_RETENTION_DAYS at panel start.
|
// SendLogRetentionEnvDefault is SEND_LOG_RETENTION_DAYS at panel start.
|
||||||
SendLogRetentionEnvDefault int
|
SendLogRetentionEnvDefault int
|
||||||
|
// InboundSpamLogRetentionEnvDefault is INBOUND_SPAM_LOG_RETENTION_DAYS at
|
||||||
|
// panel start.
|
||||||
|
InboundSpamLogRetentionEnvDefault int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server is the panel HTTP application.
|
// Server is the panel HTTP application.
|
||||||
@@ -111,7 +114,7 @@ type Server struct {
|
|||||||
// that owns DKIM keys and the OpenDKIM tables (architecture.md § OpenDKIM);
|
// that owns DKIM keys and the OpenDKIM tables (architecture.md § OpenDKIM);
|
||||||
// apps owns application SASL accounts and the Postfix sender map
|
// apps owns application SASL accounts and the Postfix sender map
|
||||||
// (architecture.md § Mail path).
|
// (architecture.md § Mail path).
|
||||||
func New(st *store.Store, domains *domain.Service, apps *app.Service, inboundSvc *inbound.Service, dmarcSvc *dmarc.Service, cfg Config, setupTokenPath string) (*Server, error) {
|
func New(st *store.Store, domains *domain.Service, apps *app.Service, inboundSvc *inbound.Service, lists *inbound.Lists, dmarcSvc *dmarc.Service, cfg Config, setupTokenPath string) (*Server, error) {
|
||||||
v, err := view.New(cfg.Version)
|
v, err := view.New(cfg.Version)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -124,7 +127,7 @@ func New(st *store.Store, domains *domain.Service, apps *app.Service, inboundSvc
|
|||||||
SessionIdleDays: cfg.SessionIdleDays,
|
SessionIdleDays: cfg.SessionIdleDays,
|
||||||
TrustedProxyCIDRs: cfg.TrustedProxyCIDRs,
|
TrustedProxyCIDRs: cfg.TrustedProxyCIDRs,
|
||||||
}, v, setupTokenPath)
|
}, v, setupTokenPath)
|
||||||
h := handlers.New(st, domains, apps, inboundSvc, dmarcSvc, handlers.Config{
|
h := handlers.New(st, domains, apps, inboundSvc, lists, dmarcSvc, handlers.Config{
|
||||||
Hostname: cfg.Hostname,
|
Hostname: cfg.Hostname,
|
||||||
SubmissionEnabled: cfg.SubmissionEnabled,
|
SubmissionEnabled: cfg.SubmissionEnabled,
|
||||||
MailLogPath: cfg.MailLogPath,
|
MailLogPath: cfg.MailLogPath,
|
||||||
@@ -143,6 +146,8 @@ func New(st *store.Store, domains *domain.Service, apps *app.Service, inboundSvc
|
|||||||
InboundAntispamAction: cfg.InboundAntispamAction,
|
InboundAntispamAction: cfg.InboundAntispamAction,
|
||||||
DMARCEnabled: cfg.DMARCEnabled,
|
DMARCEnabled: cfg.DMARCEnabled,
|
||||||
SendLogRetentionEnvDefault: cfg.SendLogRetentionEnvDefault,
|
SendLogRetentionEnvDefault: cfg.SendLogRetentionEnvDefault,
|
||||||
|
|
||||||
|
InboundSpamLogRetentionEnvDefault: cfg.InboundSpamLogRetentionEnvDefault,
|
||||||
}, v, dnscheck.New(cfg.DNSResolvers), &health.MachineSampler{}, a)
|
}, v, dnscheck.New(cfg.DNSResolvers), &health.MachineSampler{}, a)
|
||||||
return &Server{cfg: cfg, auth: a, handlers: h}, nil
|
return &Server{cfg: cfg, auth: a, handlers: h}, nil
|
||||||
}
|
}
|
||||||
@@ -240,6 +245,13 @@ func (s *Server) muxes() (public, authed *http.ServeMux) {
|
|||||||
authed.HandleFunc("POST /inbound/domains/{id}/recipients", h.HandleInboundRecipients)
|
authed.HandleFunc("POST /inbound/domains/{id}/recipients", h.HandleInboundRecipients)
|
||||||
authed.HandleFunc("GET /inbound/domains/{id}/delete", h.HandleInboundDeleteConfirm)
|
authed.HandleFunc("GET /inbound/domains/{id}/delete", h.HandleInboundDeleteConfirm)
|
||||||
authed.HandleFunc("POST /inbound/domains/{id}/delete", h.HandleInboundDelete)
|
authed.HandleFunc("POST /inbound/domains/{id}/delete", h.HandleInboundDelete)
|
||||||
|
// The log is for everyone who reaches an inbound domain (scoped to
|
||||||
|
// theirs); the lists are the instance's, so the handlers keep the
|
||||||
|
// global role's check (requireInboundGlobal) and answer 404 to the rest.
|
||||||
|
authed.HandleFunc("GET /inbound/log", h.HandleInboundLog)
|
||||||
|
authed.HandleFunc("GET /inbound/filter-lists", h.HandleFilterLists)
|
||||||
|
authed.HandleFunc("POST /inbound/filter-lists", h.HandleFilterListAdd)
|
||||||
|
authed.HandleFunc("POST /inbound/filter-lists/{id}/delete", h.HandleFilterListDelete)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server: everything under /server/ is for the global role, so the subtree
|
// Server: everything under /server/ is for the global role, so the subtree
|
||||||
|
|||||||
@@ -265,12 +265,9 @@ func testInboundRelay(t *testing.T, sc *scenario) {
|
|||||||
t.Fatalf("mail for the inbound domain never reached its upstream: %v\n%s", err, mailLogTail(h, "postfix/"))
|
t.Fatalf("mail for the inbound domain never reached its upstream: %v\n%s", err, mailLogTail(h, "postfix/"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// The inbound journal milter saw it go through. No page shows the journal
|
|
||||||
// yet, so the row is read where it is kept. No filter is attached on the
|
|
||||||
// stand: the row is an accept by nobody.
|
|
||||||
// The filter lists exist as files from the first start with the relay on,
|
// The filter lists exist as files from the first start with the relay on,
|
||||||
// readable by a filter that is another user in another container. Nothing
|
// readable by a filter that is another user in another container. Nothing
|
||||||
// is on them: no page edits them yet.
|
// is on them until someone adds an entry.
|
||||||
maps, err := h.execIn("selfpost", "sh", "-c", `cd /data/antispam && stat -c "%a %n" . *.map && { grep -vc "^#" *.map; true; }`)
|
maps, err := h.execIn("selfpost", "sh", "-c", `cd /data/antispam && stat -c "%a %n" . *.map && { grep -vc "^#" *.map; true; }`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("list /data/antispam: %v\n%s", err, maps)
|
t.Fatalf("list /data/antispam: %v\n%s", err, maps)
|
||||||
@@ -285,28 +282,57 @@ func testInboundRelay(t *testing.T, sc *scenario) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// journalHas waits for a row and returns the journal as it then stands.
|
// The inbound journal milter saw it go through, and the Inbound log shows it.
|
||||||
journalHas := func(want string) string {
|
// No filter is attached on the stand: the row is an accept by nobody, so its
|
||||||
|
// reason is the engine alone.
|
||||||
|
logRows := func(want string) []string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
var rows []string
|
||||||
|
var body string
|
||||||
for deadline := time.Now().Add(15 * time.Second); ; time.Sleep(time.Second) {
|
for deadline := time.Now().Add(15 * time.Second); ; time.Sleep(time.Second) {
|
||||||
rows, err := h.execIn("selfpost", "python3", "-c",
|
resp, b, err := sc.panel.get("/inbound/log")
|
||||||
`import sqlite3; print("\n".join("|".join(r) for r in sqlite3.connect("file:/data/selfpost.db?mode=ro", uri=True).execute("select inbound_domain, from_addr, to_addr, subject, decision, engine from inbound_spam_log")))`)
|
if err != nil || resp.StatusCode != 200 {
|
||||||
if err != nil {
|
t.Fatalf("GET /inbound/log: status %v, %v\n%.800s", resp, err, b)
|
||||||
t.Fatalf("read the inbound journal: %v\n%s", err, rows)
|
|
||||||
}
|
}
|
||||||
if strings.Contains(rows, want) {
|
body = b
|
||||||
return strings.TrimSpace(rows)
|
rows = rows[:0]
|
||||||
|
for _, row := range strings.Split(body, "<tr><td")[1:] {
|
||||||
|
rows = append(rows, row[:strings.Index(row, "</tr>")])
|
||||||
|
}
|
||||||
|
if strings.Contains(body, want) {
|
||||||
|
return rows
|
||||||
}
|
}
|
||||||
if time.Now().After(deadline) {
|
if time.Now().After(deadline) {
|
||||||
t.Fatalf("no journal row %q, journal:\n%s\n%s", want, rows, mailLogTail(h, "postfix/"))
|
t.Fatalf("no row with %q in the Inbound log:\n%.3000s\n%s", want, body, mailLogTail(h, "postfix/"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rows := journalHas(fmt.Sprintf("%s|someone@stranger.e2e.test|%s|e2e %s|accept|selfpost", inboundDomain, inboundRecipient, token))
|
// rowWith finds the one row that holds all of wants.
|
||||||
|
rowWith := func(rows []string, wants ...string) string {
|
||||||
|
t.Helper()
|
||||||
|
for _, row := range rows {
|
||||||
|
ok := true
|
||||||
|
for _, w := range wants {
|
||||||
|
ok = ok && strings.Contains(row, w)
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("no row of the Inbound log holds all of %q in\n%s", wants, strings.Join(rows, "\n"))
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := logRows("e2e " + token)
|
||||||
|
rowWith(rows,
|
||||||
|
"someone@<wbr>stranger.e2e.test", "user@<wbr>"+inboundDomain, "e2e "+token,
|
||||||
|
`<span class="tag is-success is-light">accept</span>`, `<td class="sp-muted sp-small">selfpost</td>`,
|
||||||
|
// the way to correct it, which only the global role is given
|
||||||
|
`href="/inbound/filter-lists?list=deny&kind=address&value=someone%40stranger.e2e.test">Deny sender</a>`)
|
||||||
// A report mailed to the server's own name earlier in the run came through
|
// A report mailed to the server's own name earlier in the run came through
|
||||||
// the same listener and is not the relay's mail.
|
// the same listener and is not the relay's mail.
|
||||||
if n := strings.Count(rows, "\n") + 1; n != 1 {
|
if len(rows) != 1 {
|
||||||
t.Fatalf("the journal holds %d rows, want the relayed message alone:\n%s", n, rows)
|
t.Fatalf("the Inbound log holds %d rows, want the relayed message alone:\n%s", len(rows), strings.Join(rows, "\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, to := range []string{"nobody@" + inboundDomain, "someone@elsewhere.e2e.test"} {
|
for _, to := range []string{"nobody@" + inboundDomain, "someone@elsewhere.e2e.test"} {
|
||||||
@@ -319,8 +345,71 @@ func testInboundRelay(t *testing.T, sc *scenario) {
|
|||||||
// The refusal of an unknown recipient at the inbound domain is read from
|
// The refusal of an unknown recipient at the inbound domain is read from
|
||||||
// the mail log into the journal; the refusal to relay for a stranger's
|
// the mail log into the journal; the refusal to relay for a stranger's
|
||||||
// domain is not the relay's mail and is not.
|
// domain is not the relay's mail and is not.
|
||||||
rows = journalHas(fmt.Sprintf("%s|someone@stranger.e2e.test|nobody@%s||reject|postfix", inboundDomain, inboundDomain))
|
rows = logRows("nobody@<wbr>" + inboundDomain)
|
||||||
if n := strings.Count(rows, "\n") + 1; n != 2 {
|
rowWith(rows,
|
||||||
t.Fatalf("the journal holds %d rows, want the relayed message and one refusal:\n%s", n, rows)
|
"someone@<wbr>stranger.e2e.test", "nobody@<wbr>"+inboundDomain, `<td class="sp-clip sp-muted">—</td>`,
|
||||||
|
`<span class="tag is-danger is-light">reject</span>`, `<td class="sp-muted sp-small">postfix`,
|
||||||
|
`>Deny sender</a>`)
|
||||||
|
if len(rows) != 2 {
|
||||||
|
t.Fatalf("the Inbound log holds %d rows, want the relayed message and one refusal:\n%s", len(rows), strings.Join(rows, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The filter lists: an entry added in the panel is in the map file the
|
||||||
|
// filter reads, and one removed is gone from it. Nothing reads the file on
|
||||||
|
// this stand (no filter is attached), so the file is the whole effect.
|
||||||
|
const spammer = "spammer.e2e.test"
|
||||||
|
denyDomain := func() string {
|
||||||
|
t.Helper()
|
||||||
|
out, err := h.execIn("selfpost", "cat", "/data/antispam/deny_domain.map")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read deny_domain.map: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
resp, body, err = sc.panel.postForm("/inbound/filter-lists", url.Values{
|
||||||
|
"list_type": {"deny"}, "entry_type": {"domain"}, "value": {" @" + strings.ToUpper(spammer)}, "note": {"e2e"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != 200 || resp.Request.URL.Path != "/inbound/filter-lists" || !strings.Contains(body, "Entry added.") {
|
||||||
|
t.Fatalf("add a filter list entry: status %d, landed on %s:\n%.800s", resp.StatusCode, resp.Request.URL.Path, body)
|
||||||
|
}
|
||||||
|
if got := denyDomain(); !strings.Contains(got, "\n"+spammer+"\n") {
|
||||||
|
t.Fatalf("deny_domain.map after adding %s:\n%s", spammer, got)
|
||||||
|
}
|
||||||
|
if got, err := h.execIn("selfpost", "sh", "-c", `cd /data/antispam && { grep -vc "^#" *.map; true; }`); err != nil ||
|
||||||
|
strings.Count(got, ":0") != 5 || !strings.Contains(got, "deny_domain.map:1") {
|
||||||
|
t.Fatalf("the entry is in other files than deny_domain.map (%v):\n%s", err, got)
|
||||||
|
}
|
||||||
|
// The same entry again is refused on the page, and the file still holds it once.
|
||||||
|
resp, body, err = sc.panel.postForm("/inbound/filter-lists", url.Values{
|
||||||
|
"list_type": {"deny"}, "entry_type": {"domain"}, "value": {spammer}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != 422 || !strings.Contains(body, "That entry is already on the list.") {
|
||||||
|
t.Fatalf("add the same entry again: status %d:\n%.800s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
if got := denyDomain(); strings.Count(got, spammer) != 1 {
|
||||||
|
t.Fatalf("deny_domain.map after a duplicate:\n%s", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, body, err = sc.panel.get("/inbound/filter-lists")
|
||||||
|
if err != nil || resp.StatusCode != 200 {
|
||||||
|
t.Fatalf("GET /inbound/filter-lists: status %v, %v", resp, err)
|
||||||
|
}
|
||||||
|
m = regexp.MustCompile(`action="(/inbound/filter-lists/\d+/delete)"`).FindStringSubmatch(body)
|
||||||
|
if m == nil || !strings.Contains(body, spammer) {
|
||||||
|
t.Fatalf("the list does not show %s with a Remove:\n%.2000s", spammer, body)
|
||||||
|
}
|
||||||
|
resp, body, err = sc.panel.postForm(m[1], nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != 200 || resp.Request.URL.Path != "/inbound/filter-lists" || !strings.Contains(body, "Entry removed.") {
|
||||||
|
t.Fatalf("remove the entry: status %d, landed on %s:\n%.800s", resp.StatusCode, resp.Request.URL.Path, body)
|
||||||
|
}
|
||||||
|
if got := denyDomain(); strings.Contains(got, spammer) {
|
||||||
|
t.Fatalf("deny_domain.map after removing %s:\n%s", spammer, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user