panel-redesign: three choices for a domain's report address; row actions

Follows the design and guard commits before it. Written by Sonnet,
reviewed by Opus.

The report address
  - Baseline: users.dmarc_default_mode, users.dmarc_default_address and
    domains.dmarc_rua_user_id are gone. domains.dmarc_rua alone says
    where reports go: '' is no reports (a new domain), the address
    SelfPost derives for the domain is the hosted choice (while hosted
    reports are on), anything else is a typed address.
  - Removed: the user default and its store functions, a domain
    following a user, keep / inherit, followCreator, POST /account/dmarc
    with its handler, the DMARC box of Account, the user-delete item
    about domains losing their address.
  - Domain settings offers the three choices; beside the typed address a
    "Use my e-mail" button fills in the profile e-mail (panel.js: one
    generic data-fill hook; absent without a profile e-mail; it fills,
    it does not save).
  - The DMARC record's note names the address and no user. This closes
    L4 of the security review.
  - Domain export / import carries the address as a plain string.
  - The ingest allow-list is the generic mailbox plus each domain's
    address on this host; the generic mailbox is always open now.
  - dev/migrate1x (not in the tree): a 1.x domain that inherited the
    instance default gets that address as its own, so no published rua=
    changes.

Row actions
  - row_action partial (RowAction: Label, Post, Confirm, Danger);
    panel.css has the mockup's three rules. A row of the Applications
    table is Edit, New password, Delete; the confirmation texts are the
    ones the application page's head uses. Both roles, with the checks
    of the application page.

Tests cover: a new domain has no reports; each choice saved and shown
selected; hosted refused when hosted reports are off; a bad address
refused with what was typed kept; an untouched save changes nothing in
each state; the fill button with and without a profile e-mail; deleting
a user leaves every domain's address; the row's forms, targets and
confirmations for both roles and a 404 for a stranger.

Known: if hosted reports are switched off later, a stored hosted address
reads as a typed one. docs/guide.md still describes the old choices -
the documentation step.

Evidence
  - docs/assets/panel-redesign/evidence/account.png,
    out-domain-settings.png, out-domain.png, user-delete.png, help.png:
    1440 px wide, headless Edge, real templates served locally with
    their fixtures - NOT from the container.
  - panel/audit.js on out-domain, out-domain-settings, account, help,
    components and user-delete at 1440 and 1280: 1420 texts checked,
    problems: [].
  - In a browser: the fill button sets the field and does not submit;
    the row buttons have the font, size and colour of the Edit link,
    Delete the red of the other delete links; data-confirm fires on
    both. Hover and focus states were not observed.
  - go test -count=1 ./internal/web/... ./internal/store/...
    ./internal/dmarc/... ./internal/app/... ./internal/inbound/...
    ./cmd/panel: ok (ten packages; dev/migrate1x also ok).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-10 12:38:35 +03:00
co-authored by Claude Opus 5.5
parent e15f8896c5
commit 96db0b7a86
49 changed files with 768 additions and 1214 deletions
+5 -95
View File
@@ -29,59 +29,15 @@ const (
RoleDomain Role = "domain"
)
// How a user's default DMARC report address is chosen (users.dmarc_default_mode).
const (
DMARCDefaultHosted = "hosted" // SelfPost's own mailbox for the domain
DMARCDefaultAccount = "account" // the user's account e-mail
DMARCDefaultCustom = "custom" // DMARCDefault.Address
DMARCDefaultNone = "none" // no aggregate reports
)
// DMARCDefault is a user's default report address: the choice a domain takes
// over when it follows that user (Domain.DMARCRuaUserID).
type DMARCDefault struct {
UserID int64
Username string
Mode string
Address string // for DMARCDefaultCustom
Email string // the account e-mail, for DMARCDefaultAccount
}
// Resolve returns the address the default stands for on one domain. hosted is
// the SelfPost-hosted mailbox for that domain, "" when report ingest is off —
// a hosted default then resolves to no reports.
func (d DMARCDefault) Resolve(hosted string) string {
switch d.Mode {
case DMARCDefaultHosted:
return hosted
case DMARCDefaultAccount:
return d.Email
case DMARCDefaultCustom:
return d.Address
}
return ""
}
// ValidDMARCDefaultMode reports whether mode is one of the stored choices.
func ValidDMARCDefaultMode(mode string) bool {
switch mode {
case DMARCDefaultHosted, DMARCDefaultAccount, DMARCDefaultCustom, DMARCDefaultNone:
return true
}
return false
}
// User is a panel login (not an application SASL account).
type User struct {
ID int64
Username string
PasswordHash string
Role Role
// Email is the user's own address — what the panel writes to. DMARC may
// use it (DMARCDefaultAccount) but it is not a DMARC field.
Email string
DMARCDefaultMode string
DMARCDefaultAddress string
// Email is the user's own address — what the panel writes to, and what the
// button beside a domain's typed report address fills it with.
Email string
// AllDomains / AllInboundDomains widen a domain user's list to every
// domain of that direction, including those added later; the id lists
// are then ignored.
@@ -92,19 +48,14 @@ type User struct {
InboundDomainIDs []int64
}
// DMARCDefault returns the user's default report address choice.
func (u User) DMARCDefault() DMARCDefault {
return DMARCDefault{UserID: u.ID, Username: u.Username, Mode: u.DMARCDefaultMode, Address: u.DMARCDefaultAddress, Email: u.Email}
}
const userColumns = "id, username, password_hash, role, email, dmarc_default_mode, dmarc_default_address, all_domains, all_inbound_domains, created_at"
const userColumns = "id, username, password_hash, role, email, all_domains, all_inbound_domains, created_at"
func scanUser(r scanRow) (User, error) {
var (
u User
createdAt string
)
err := r.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.Email, &u.DMARCDefaultMode, &u.DMARCDefaultAddress,
err := r.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.Email,
&u.AllDomains, &u.AllInboundDomains, &createdAt)
if err != nil {
return User{}, err
@@ -318,47 +269,6 @@ func (s *Store) UpdateUser(id int64, username, passwordHash, email string) error
return nil
}
// SetDMARCDefault stores the user's default report address choice. address is
// kept only for DMARCDefaultCustom. Every domain that follows this user
// resolves to the new choice from the next read on.
func (s *Store) SetDMARCDefault(userID int64, mode, address string) error {
if !ValidDMARCDefaultMode(mode) {
return fmt.Errorf("set dmarc default: unknown mode %q", mode)
}
if mode != DMARCDefaultCustom {
address = ""
}
res, err := s.db.Exec(
"UPDATE users SET dmarc_default_mode = ?, dmarc_default_address = ? WHERE id = ?", mode, address, userID)
if err != nil {
return fmt.Errorf("set dmarc default: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("set dmarc default: %w", err)
}
if n == 0 {
return ErrUserNotFound
}
return nil
}
// GetDMARCDefault returns a user's default report address choice, or
// ErrUserNotFound.
func (s *Store) GetDMARCDefault(userID int64) (DMARCDefault, error) {
d := DMARCDefault{UserID: userID}
err := s.db.QueryRow(
"SELECT username, dmarc_default_mode, dmarc_default_address, email FROM users WHERE id = ?", userID,
).Scan(&d.Username, &d.Mode, &d.Address, &d.Email)
if errors.Is(err, sql.ErrNoRows) {
return DMARCDefault{}, ErrUserNotFound
}
if err != nil {
return DMARCDefault{}, fmt.Errorf("get dmarc default: %w", err)
}
return d, nil
}
// SetUserRole updates a user's role.
func (s *Store) SetUserRole(userID int64, role Role) error {
res, err := s.db.Exec("UPDATE users SET role = ? WHERE id = ?", role, userID)