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:
@@ -57,3 +57,13 @@ func domainNameSet(domains []store.Domain) map[string]bool {
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// domainIDSet is the set of the ids of domains; a send-log row's DomainID is
|
||||
// in it when the row belongs to one of them (0, a row of no domain, never is).
|
||||
func domainIDSet(domains []store.Domain) map[int64]bool {
|
||||
m := make(map[int64]bool, len(domains))
|
||||
for _, d := range domains {
|
||||
m[d.ID] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -189,6 +189,10 @@ func TestOverviewPageShowsVerdictsAndDomains(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("AddDomain: %v", err)
|
||||
}
|
||||
// A row is the domain's through its application (send_log.domain_id).
|
||||
if _, err := h.store.AddApplication(d.ID, "app", store.AddressModeWildcard, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.store.InsertQueued(store.SendLogEntry{QueueID: "Q1", Domain: d.Name, AppLogin: "app", From: "noreply@" + d.Name, To: "public@example.net", Subject: "Hello"}); err != nil {
|
||||
t.Fatalf("InsertQueued: %v", err)
|
||||
}
|
||||
|
||||
@@ -39,9 +39,11 @@ func (h *Handlers) HandleInboundLog(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
names := make([]string, 0, len(assigned))
|
||||
ids := make([]int64, 0, len(assigned))
|
||||
offered := make(map[string]bool, len(assigned))
|
||||
for _, d := range assigned {
|
||||
names = append(names, d.Name)
|
||||
ids = append(ids, d.ID)
|
||||
offered[d.Name] = true
|
||||
}
|
||||
|
||||
@@ -65,7 +67,7 @@ func (h *Handlers) HandleInboundLog(w http.ResponseWriter, r *http.Request) {
|
||||
// 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
|
||||
query.DomainIDs = ids
|
||||
}
|
||||
|
||||
total, err := h.store.CountInboundSpam(query)
|
||||
|
||||
@@ -94,7 +94,7 @@ func (h *Handlers) HandleDelivery(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !p.IsGlobal() && !domainNameSet(allowed)[row.Domain] {
|
||||
if !p.IsGlobal() && !domainIDSet(allowed)[row.DomainID] {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
@@ -113,7 +113,7 @@ func (h *Handlers) HandleDelivery(w http.ResponseWriter, r *http.Request) {
|
||||
// The names link to their pages when there is a page to go to: a domain
|
||||
// that was deleted since, or an application of another domain, is text.
|
||||
for _, d := range allowed {
|
||||
if d.Name != row.Domain {
|
||||
if d.ID != row.DomainID {
|
||||
continue
|
||||
}
|
||||
in.DomainHref = view.DomainHref(d.ID)
|
||||
@@ -317,8 +317,10 @@ func (h *Handlers) outLogPage(r *http.Request) (*view.OutLog, error) {
|
||||
allowedNames := domainNameSet(assigned)
|
||||
|
||||
domainNames := make([]string, 0, len(assigned))
|
||||
domainIDs := make([]int64, 0, len(assigned))
|
||||
for _, d := range assigned {
|
||||
domainNames = append(domainNames, d.Name)
|
||||
domainIDs = append(domainIDs, d.ID)
|
||||
}
|
||||
|
||||
loginSet := make(map[string]bool)
|
||||
@@ -341,8 +343,10 @@ func (h *Handlers) outLogPage(r *http.Request) (*view.OutLog, error) {
|
||||
Domain: q.Get("domain"),
|
||||
AppLogin: q.Get("app"),
|
||||
// A global administrator reads the whole journal, including rows left
|
||||
// behind by a domain that has since been deleted.
|
||||
Domains: domainNames,
|
||||
// behind by a domain that has since been deleted; anyone else the rows
|
||||
// of the domains assigned to them, by id - not those an earlier domain
|
||||
// of the same name left (audit 2026-10-12, L12).
|
||||
DomainIDs: domainIDs,
|
||||
AllDomains: p.IsGlobal(),
|
||||
}
|
||||
if !p.IsGlobal() {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/auth"
|
||||
)
|
||||
|
||||
// The users form's writes, driven through the handlers (audit 2026-10-12, M7
|
||||
@@ -97,3 +98,55 @@ func TestUserFormCreatesWithEmailAndReach(t *testing.T) {
|
||||
t.Fatalf("created %+v", u)
|
||||
}
|
||||
}
|
||||
|
||||
// A domain deleted and added again under its old name starts with an empty
|
||||
// log for whoever it is assigned to; its earlier rows are the global role's
|
||||
// alone (audit 2026-10-12, L12).
|
||||
func TestADomainAddedAgainShowsItsAssigneeNothingOld(t *testing.T) {
|
||||
h, st := inboundHandlers(t)
|
||||
root := userWith(t, st, "root", store.RoleGlobal, store.Reach{})
|
||||
old, _ := st.AddDomain("shop.example", "mail")
|
||||
st.AddApplication(old.ID, "shop", store.AddressModeWildcard, nil)
|
||||
if err := st.InsertQueued(store.SendLogEntry{QueueID: "OLDQ", Domain: "shop.example", AppLogin: "shop", To: "a@example.net", Subject: "old mail"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.DeleteDomain(old.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, _ := st.AddDomain("shop.example", "mail")
|
||||
st.AddApplication(again.ID, "shop", store.AddressModeWildcard, nil)
|
||||
if err := st.InsertQueued(store.SendLogEntry{QueueID: "NEWQ", Domain: "shop.example", AppLogin: "shop", To: "a@example.net", Subject: "new mail"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner := userWith(t, st, "owner", store.RoleDomain, store.Reach{DomainIDs: []int64{again.ID}})
|
||||
rows, _ := st.QuerySendLog(store.SendLogFilter{AllDomains: true}, 10, 0)
|
||||
var oldID, newID int64
|
||||
for _, r := range rows {
|
||||
if r.QueueID == "OLDQ" {
|
||||
oldID = r.ID
|
||||
} else {
|
||||
newID = r.ID
|
||||
}
|
||||
}
|
||||
|
||||
page := func(p *auth.Principal, id int64) int {
|
||||
return send(h.HandleDelivery, p, "GET", "/outbound/log/"+idStr(id), map[string]string{"id": idStr(id)}, nil).Code
|
||||
}
|
||||
if code := page(&owner, oldID); code != http.StatusNotFound {
|
||||
t.Errorf("the old row's page for the new assignee = %d, want 404", code)
|
||||
}
|
||||
if code := page(&owner, newID); code != http.StatusOK {
|
||||
t.Errorf("the new row's page for its assignee = %d, want 200", code)
|
||||
}
|
||||
if code := page(&root, oldID); code != http.StatusOK {
|
||||
t.Errorf("the old row's page for the global role = %d, want 200", code)
|
||||
}
|
||||
body := send(h.HandleDeliveries, &owner, "GET", "/outbound/log", nil, nil).Body.String()
|
||||
if strings.Contains(body, "old mail") || !strings.Contains(body, "new mail") {
|
||||
t.Errorf("the new assignee's log shows the old row or misses the new one")
|
||||
}
|
||||
body = send(h.HandleDeliveries, &root, "GET", "/outbound/log", nil, nil).Body.String()
|
||||
if !strings.Contains(body, "old mail") || !strings.Contains(body, "new mail") {
|
||||
t.Errorf("the global log is missing a row")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user