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:
+32
-22
@@ -40,6 +40,12 @@ type SendLogEntry struct {
|
||||
HeaderFrom string
|
||||
}
|
||||
|
||||
// loginDomainID is the domain_id a row is written with: the domain of the
|
||||
// application whose login it is, NULL for a login that is no application's
|
||||
// (audit 2026-10-12, L12). The domain name of the row stays what the
|
||||
// envelope said.
|
||||
const loginDomainID = `(SELECT a.domain_id FROM applications a WHERE a.login = ?)`
|
||||
|
||||
// InsertQueued records an accepted message in the send log with status
|
||||
// "queued". It is called from the journal-milter hot path, so it returns any
|
||||
// error for the caller to log rather than deciding policy here; the milter
|
||||
@@ -48,9 +54,9 @@ func (s *Store) InsertQueued(e SendLogEntry) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO send_log
|
||||
(queue_id, domain, app_login, from_addr, to_addr, subject, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
e.QueueID, e.Domain, e.AppLogin, e.From, e.To, e.Subject, StatusQueued, now, now,
|
||||
(queue_id, domain, app_login, from_addr, to_addr, subject, status, created_at, updated_at, domain_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, `+loginDomainID+`)`,
|
||||
e.QueueID, e.Domain, e.AppLogin, e.From, e.To, e.Subject, StatusQueued, now, now, e.AppLogin,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert send_log: %w", err)
|
||||
@@ -67,9 +73,9 @@ func (s *Store) InsertRejected(e SendLogEntry) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO send_log
|
||||
(queue_id, domain, app_login, from_addr, to_addr, subject, status, reason, header_from, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
e.QueueID, e.Domain, e.AppLogin, e.From, e.To, e.Subject, StatusRejected, e.Reason, e.HeaderFrom, now, now,
|
||||
(queue_id, domain, app_login, from_addr, to_addr, subject, status, reason, header_from, created_at, updated_at, domain_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, `+loginDomainID+`)`,
|
||||
e.QueueID, e.Domain, e.AppLogin, e.From, e.To, e.Subject, StatusRejected, e.Reason, e.HeaderFrom, now, now, e.AppLogin,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert rejected send_log: %w", err)
|
||||
@@ -201,9 +207,12 @@ func (s *Store) ListQueuedOlderThan(cutoff time.Time) ([]QueuedDelivery, error)
|
||||
// Persistence): a SendLogEntry plus the fields that only exist once a row has
|
||||
// been written (id, current status, timestamps).
|
||||
type SendLogRow struct {
|
||||
ID int64
|
||||
QueueID string
|
||||
Domain string
|
||||
ID int64
|
||||
QueueID string
|
||||
Domain string
|
||||
// DomainID is the domain the row belongs to; 0 when it is nobody's but
|
||||
// the global role's (its domain was deleted, or its login was none).
|
||||
DomainID int64
|
||||
AppLogin string
|
||||
From string
|
||||
To string
|
||||
@@ -231,9 +240,9 @@ func (s *Store) GetSendLog(id int64) (SendLogRow, error) {
|
||||
createdAt, updatedAt string
|
||||
)
|
||||
err := s.db.QueryRow(
|
||||
`SELECT id, queue_id, domain, app_login, from_addr, to_addr, subject, status, reason, header_from, created_at, updated_at
|
||||
`SELECT id, queue_id, domain, COALESCE(domain_id, 0), app_login, from_addr, to_addr, subject, status, reason, header_from, created_at, updated_at
|
||||
FROM send_log WHERE id = ?`, id,
|
||||
).Scan(&row.ID, &row.QueueID, &row.Domain, &row.AppLogin, &row.From, &row.To,
|
||||
).Scan(&row.ID, &row.QueueID, &row.Domain, &row.DomainID, &row.AppLogin, &row.From, &row.To,
|
||||
&row.Subject, &row.Status, &row.Reason, &row.HeaderFrom, &createdAt, &updatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return SendLogRow{}, ErrSendLogNotFound
|
||||
@@ -252,9 +261,10 @@ func (s *Store) GetSendLog(id int64) (SendLogRow, error) {
|
||||
// Domain and AppLogin are the operator's own filters, chosen in the UI: an
|
||||
// empty field matches everything.
|
||||
//
|
||||
// Domains and AllDomains are the authorization scope, which no query parameter
|
||||
// may widen. Domains is the exhaustive set of domain names the caller is
|
||||
// entitled to read, applied as an IN constraint; AllDomains lifts that
|
||||
// DomainIDs and AllDomains are the authorization scope, which no query
|
||||
// parameter may widen. DomainIDs is the exhaustive set of domains the caller
|
||||
// is entitled to read, by id - a row whose domain was deleted is nobody's
|
||||
// (audit 2026-10-12, L12) - applied as an IN constraint; AllDomains lifts that
|
||||
// restriction and is the only way to read the whole journal. A zero-valued
|
||||
// filter therefore matches *no* rows: a caller that forgets to state a scope
|
||||
// gets an empty log rather than every tenant's mail, which is the failure mode
|
||||
@@ -262,7 +272,7 @@ func (s *Store) GetSendLog(id int64) (SendLogRow, error) {
|
||||
type SendLogFilter struct {
|
||||
Domain string
|
||||
AppLogin string
|
||||
Domains []string
|
||||
DomainIDs []int64
|
||||
AllDomains bool
|
||||
}
|
||||
|
||||
@@ -273,7 +283,7 @@ func (s *Store) QuerySendLog(filter SendLogFilter, limit, offset int) ([]SendLog
|
||||
where, args := sendLogWhere(filter)
|
||||
args = append(args, limit, offset)
|
||||
rows, err := s.db.Query(
|
||||
`SELECT id, queue_id, domain, app_login, from_addr, to_addr, subject, status, reason, header_from, created_at, updated_at
|
||||
`SELECT id, queue_id, domain, COALESCE(domain_id, 0), app_login, from_addr, to_addr, subject, status, reason, header_from, created_at, updated_at
|
||||
FROM send_log`+where+`
|
||||
ORDER BY id DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
@@ -290,7 +300,7 @@ func (s *Store) QuerySendLog(filter SendLogFilter, limit, offset int) ([]SendLog
|
||||
row SendLogRow
|
||||
createdAt, updatedAt string
|
||||
)
|
||||
if err := rows.Scan(&row.ID, &row.QueueID, &row.Domain, &row.AppLogin,
|
||||
if err := rows.Scan(&row.ID, &row.QueueID, &row.Domain, &row.DomainID, &row.AppLogin,
|
||||
&row.From, &row.To, &row.Subject, &row.Status, &row.Reason, &row.HeaderFrom, &createdAt, &updatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan send_log row: %w", err)
|
||||
}
|
||||
@@ -319,15 +329,15 @@ func sendLogWhere(f SendLogFilter) (string, []any) {
|
||||
// No scope is not "no restriction": a domain administrator whose last
|
||||
// assignment was deleted owns nothing in the journal and must see
|
||||
// nothing, and the same clause catches a caller that never set a scope.
|
||||
if len(f.Domains) == 0 {
|
||||
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, "domain IN ("+strings.Join(marks, ", ")+")")
|
||||
clauses = append(clauses, "domain_id IN ("+strings.Join(marks, ", ")+")")
|
||||
}
|
||||
if f.Domain != "" {
|
||||
clauses = append(clauses, "domain = ?")
|
||||
|
||||
Reference in New Issue
Block a user