panel-redesign: the 2.0 schema from zero - one baseline, no 1.x chain
Stage 1, step 1. BREAKING: a 1.x database is not migrated.
Schema
- internal/store/migrations/ holds one file, 0001_init.sql, equal to the
2.0 schema; 0002..0010 are deleted (they stay in history at 1.9.5). No
admin table created and dropped, no ALTERs.
- users.email replaces users.dmarc_report_email: the address is the
user's own. The default DMARC report address is a user's setting
(dmarc_default_mode hosted/account/custom/none, dmarc_default_address);
the instance setting that mirrored the global user's field is gone.
- domains.dmarc_rua_user_id: a domain follows a named user's default, or
has an address of its own in dmarc_rua ('' = none). ON DELETE SET NULL
leaves it at "no reports", never at someone else's default.
- user_inbound_domains, users.all_domains, users.all_inbound_domains: in
the schema for the inbound delegation of the next step.
- users.role is 'global' / 'domain' (was 'domain_admin');
rate_limits.allowed_ips, unused since 1.9.0, is dropped.
Opening a database
- The baseline sets PRAGMA application_id ("SP20"). store.Open refuses a
file that has a schema version without it (ErrForeignSchema) and one
newer than the build. A 1.x file at user_version 10 would otherwise read
as already migrated. The refused file is left untouched.
Code
- Store.DomainDMARCRua is the one place a domain's report address is
resolved; dnscheck.ResolveDMARCRua and Store.GlobalDMARCReportEmail are
gone. dmarc.AllowedRecipients, the domain page, the dashboard and the
DNS re-check go through it.
- The old templates keep working on the new model until their pages are
restyled: the Settings form's address field is the account e-mail and
switches the user's default between it and none; a domain's "Same as
Settings" makes it follow the user who chose it; a new or imported
domain follows its creator.
- Domain export/import: a domain that follows a user exports no address.
Tests: the baseline pinned table by table (TestBaselineSchema), refusal of a
1.x and of a newer database, constraints and cascades of the new columns and
table, resolution in every default mode, the deleted-user case, and the three
bridged handlers.
docs/schema-migrations.md is rewritten for the baseline; roadmap 11/27,
schema-squash marked built.
go test ./... (Windows)
ok for every package except two that fail the same way before this commit:
internal/domain TestWriteLoadPrivateKeyRoundtrip (file mode on NTFS)
internal/logtail TestFollowTailsAndRotates (rename of an open file)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+44
-12
@@ -25,9 +25,13 @@ type Domain struct {
|
||||
ID int64
|
||||
Name string
|
||||
DKIMSelector string
|
||||
DMARCRua sql.NullString // NULL = inherit profile; Valid+empty = no reports
|
||||
CreatedAt time.Time
|
||||
AppCount int
|
||||
// Where DMARC aggregate reports go. With DMARCRuaUserID set the domain
|
||||
// follows that user's default (DMARCDefault) and DMARCRua is ignored;
|
||||
// otherwise DMARCRua is the address itself, "" meaning no reports.
|
||||
DMARCRua string
|
||||
DMARCRuaUserID sql.NullInt64
|
||||
CreatedAt time.Time
|
||||
AppCount int
|
||||
}
|
||||
|
||||
// AddDomain inserts a new sending domain. The caller is responsible for having
|
||||
@@ -56,7 +60,7 @@ func (s *Store) AddDomain(name, selector string) (Domain, error) {
|
||||
// ordered by name.
|
||||
func (s *Store) ListDomains() ([]Domain, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.created_at,
|
||||
SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.dmarc_rua_user_id, d.created_at,
|
||||
(SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id)
|
||||
FROM domains d
|
||||
ORDER BY d.name`)
|
||||
@@ -80,7 +84,7 @@ func (s *Store) ListDomains() ([]Domain, error) {
|
||||
// ordered by name.
|
||||
func (s *Store) ListDomainsForUser(userID int64) ([]Domain, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.created_at,
|
||||
SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.dmarc_rua_user_id, d.created_at,
|
||||
(SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id)
|
||||
FROM domains d
|
||||
INNER JOIN user_domains ud ON ud.domain_id = d.id
|
||||
@@ -106,7 +110,7 @@ func (s *Store) ListDomainsForUser(userID int64) ([]Domain, error) {
|
||||
// ErrDomainNotFound.
|
||||
func (s *Store) GetDomain(id int64) (Domain, error) {
|
||||
row := s.db.QueryRow(`
|
||||
SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.created_at,
|
||||
SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.dmarc_rua_user_id, d.created_at,
|
||||
(SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id)
|
||||
FROM domains d
|
||||
WHERE d.id = ?`, id)
|
||||
@@ -148,7 +152,7 @@ func scanDomain(r scanRow) (Domain, error) {
|
||||
d Domain
|
||||
createdAt string
|
||||
)
|
||||
if err := r.Scan(&d.ID, &d.Name, &d.DKIMSelector, &d.DMARCRua, &createdAt, &d.AppCount); err != nil {
|
||||
if err := r.Scan(&d.ID, &d.Name, &d.DKIMSelector, &d.DMARCRua, &d.DMARCRuaUserID, &createdAt, &d.AppCount); err != nil {
|
||||
return Domain{}, err
|
||||
}
|
||||
d.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
@@ -166,11 +170,21 @@ func isUniqueViolation(err error) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// UpdateDomainDMARCRua sets how this domain resolves its DMARC rua= destination.
|
||||
// NULL means inherit the administrator profile; Valid with an empty string means
|
||||
// policy-only with no aggregate reports for this domain.
|
||||
func (s *Store) UpdateDomainDMARCRua(id int64, rua sql.NullString) error {
|
||||
res, err := s.db.Exec("UPDATE domains SET dmarc_rua = ? WHERE id = ?", rua, id)
|
||||
// SetDomainDMARCAddress gives the domain a report address of its own; "" means
|
||||
// no aggregate reports. The domain stops following anyone's default.
|
||||
func (s *Store) SetDomainDMARCAddress(id int64, rua string) error {
|
||||
return s.updateDomainDMARC(id, rua, sql.NullInt64{})
|
||||
}
|
||||
|
||||
// SetDomainDMARCUser makes the domain follow userID's default report address
|
||||
// (DMARCDefault). If that user is later deleted the domain falls back to no
|
||||
// reports rather than to someone else's default.
|
||||
func (s *Store) SetDomainDMARCUser(id, userID int64) error {
|
||||
return s.updateDomainDMARC(id, "", sql.NullInt64{Int64: userID, Valid: true})
|
||||
}
|
||||
|
||||
func (s *Store) updateDomainDMARC(id int64, rua string, userID sql.NullInt64) error {
|
||||
res, err := s.db.Exec("UPDATE domains SET dmarc_rua = ?, dmarc_rua_user_id = ? WHERE id = ?", rua, userID, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update domain dmarc rua: %w", err)
|
||||
}
|
||||
@@ -184,6 +198,24 @@ func (s *Store) UpdateDomainDMARCRua(id int64, rua sql.NullString) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DomainDMARCRua resolves where the domain's aggregate reports go: its own
|
||||
// address, or the default of the user it follows. hosted is the SelfPost-hosted
|
||||
// mailbox for this domain, "" when report ingest is off (the store does not
|
||||
// know the server's hostname). The result is "" for a policy-only record.
|
||||
func (s *Store) DomainDMARCRua(d Domain, hosted string) (string, error) {
|
||||
if !d.DMARCRuaUserID.Valid {
|
||||
return d.DMARCRua, nil
|
||||
}
|
||||
def, err := s.GetDMARCDefault(d.DMARCRuaUserID.Int64)
|
||||
if errors.Is(err, ErrUserNotFound) {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return def.Resolve(hosted), nil
|
||||
}
|
||||
|
||||
// DomainExists reports whether a sending domain with this name is configured.
|
||||
// Used by the DMARC ingest path to refuse reports for domains this relay does
|
||||
// not send for (security.md: report XML is attacker-supplied input).
|
||||
|
||||
Reference in New Issue
Block a user