send log: rows belong to a domain by id (audit L12, 0008)
A domain user's log, the message page, a domain's statistics, the level-2 count and the notifier's refusal count picked send-log rows by domain name, so a domain deleted and re-added under its old name showed its earlier mail to the next assignee and went on counting it. Migration 0008 (not run on the live database yet, so extended here): send_log.domain_id and inbound_spam_log.inbound_domain_id, REFERENCES ... ON DELETE SET NULL, filled once by name from the registry as it is at migration time, each with an (id, created_at) index. - Writes: InsertQueued / InsertRejected set domain_id to the domain of the row's login (loginDomainID) - the journal milter and the sender check's refusal rows both go through them; the inbound journal sets inbound_domain_id from the inbound domain's name at insert. - Reads: SendLogFilter.DomainIDs (was Domains, by name) and InboundSpamFilter.DomainIDs are the scope; the global role still reads everything. The message page checks row.DomainID against the user's domains. DomainSendStats and CountMessages keep their name argument and select domain_id = (SELECT id FROM domains WHERE name = ?). The notifier joins send_log.domain_id to the domain and its application. - DMARC reports stay by name on purpose (a report is about the DNS name). Tests: store - scope by ids; a re-added domain's log, level-2 count and statistics hold its own row only, the global log both; inbound scope by ids. Handlers - the new assignee gets 404 for the old row's page and does not see it in the log, the global role sees both. Tests that wrote rows for a login that is no application now register it (a row is a domain's through its application). Schema at 0008 with the two columns and indexes. schema-migrations row 8, CHANGELOG [Unreleased], audit plan line ticked, roadmap 19/62. Evidence: go vet ./... clean; go test ./... ok locally but for the two Windows-only tests; the server run follows before the push. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -42,6 +42,10 @@ type InboundSpamEntry struct {
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// inboundDomainID is the inbound_domain_id a journal row is written with: the
|
||||
// inbound domain registered under the row's name now (audit 2026-10-12, L12).
|
||||
const inboundDomainID = `(SELECT d.id FROM inbound_domains d WHERE d.name = ?)`
|
||||
|
||||
// InsertInboundSpam records one journal row. It is called from the inbound
|
||||
// journal milter's hot path and from the log tailer, so it returns any error
|
||||
// for the caller to log; neither of them may hold up mail over it.
|
||||
@@ -52,10 +56,10 @@ func (s *Store) InsertInboundSpam(e InboundSpamEntry) error {
|
||||
}
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO inbound_spam_log
|
||||
(inbound_domain, client_ip, from_addr, to_addr, subject, decision, engine, detail, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
(inbound_domain, client_ip, from_addr, to_addr, subject, decision, engine, detail, created_at, inbound_domain_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, `+inboundDomainID+`)`,
|
||||
e.InboundDomain, e.ClientIP, e.From, e.To, e.Subject, e.Decision, e.Engine, e.Detail,
|
||||
at.UTC().Format(time.RFC3339),
|
||||
at.UTC().Format(time.RFC3339), e.InboundDomain,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert inbound_spam_log: %w", err)
|
||||
@@ -73,13 +77,13 @@ func (s *Store) InsertInboundSpamOnce(e InboundSpamEntry) (bool, error) {
|
||||
at := e.At.UTC().Format(time.RFC3339)
|
||||
res, err := s.db.Exec(
|
||||
`INSERT INTO inbound_spam_log
|
||||
(inbound_domain, client_ip, from_addr, to_addr, subject, decision, engine, detail, created_at)
|
||||
SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
(inbound_domain, client_ip, from_addr, to_addr, subject, decision, engine, detail, created_at, inbound_domain_id)
|
||||
SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, `+inboundDomainID+`
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM inbound_spam_log
|
||||
WHERE created_at = ? AND client_ip = ? AND from_addr = ? AND to_addr = ?
|
||||
AND decision = ? AND engine = ? AND detail = ?)`,
|
||||
e.InboundDomain, e.ClientIP, e.From, e.To, e.Subject, e.Decision, e.Engine, e.Detail, at,
|
||||
e.InboundDomain, e.ClientIP, e.From, e.To, e.Subject, e.Decision, e.Engine, e.Detail, at, e.InboundDomain,
|
||||
at, e.ClientIP, e.From, e.To, e.Decision, e.Engine, e.Detail,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -133,9 +137,10 @@ type InboundSpamRow struct {
|
||||
// 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
|
||||
// DomainIDs is the authorization scope, which no query parameter may widen:
|
||||
// the exhaustive set of inbound domains the caller may read, by id - a row of
|
||||
// a domain deleted since is nobody's (audit 2026-10-12, L12) - 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 -
|
||||
@@ -145,7 +150,9 @@ type InboundSpamFilter struct {
|
||||
Decision string
|
||||
Since time.Time
|
||||
Until time.Time
|
||||
Domains []string
|
||||
// DomainIDs: the inbound domains the caller may read, by id; nil reads
|
||||
// everything (the global role).
|
||||
DomainIDs []int64
|
||||
}
|
||||
|
||||
// QueryInboundSpam returns journal rows matching filter, newest first, for
|
||||
@@ -197,16 +204,16 @@ func (s *Store) CountInboundSpam(filter InboundSpamFilter) (int64, error) {
|
||||
func inboundSpamWhere(f InboundSpamFilter) (string, []any) {
|
||||
var clauses []string
|
||||
var args []any
|
||||
if f.Domains != nil {
|
||||
if len(f.Domains) == 0 {
|
||||
if f.DomainIDs != nil {
|
||||
if len(f.DomainIDs) == 0 {
|
||||
return " WHERE 1 = 0", nil
|
||||
}
|
||||
marks := make([]string, len(f.Domains))
|
||||
for i, name := range f.Domains {
|
||||
marks := make([]string, len(f.DomainIDs))
|
||||
for i, id := range f.DomainIDs {
|
||||
marks[i] = "?"
|
||||
args = append(args, name)
|
||||
args = append(args, id)
|
||||
}
|
||||
clauses = append(clauses, "inbound_domain IN ("+strings.Join(marks, ", ")+")")
|
||||
clauses = append(clauses, "inbound_domain_id IN ("+strings.Join(marks, ", ")+")")
|
||||
}
|
||||
if f.Domain != "" {
|
||||
clauses = append(clauses, "inbound_domain = ?")
|
||||
|
||||
Reference in New Issue
Block a user