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
-7
View File
@@ -50,13 +50,6 @@ func (h *Handlers) hostedDMARCAddress(domainName string) string {
return dmarc.HostedReportAddress(h.cfg.Hostname, domainName)
}
// domainReportAddress resolves where a domain's DMARC aggregate reports go:
// its own address, or the default of the user it follows. "" is a policy-only
// record.
func (h *Handlers) domainReportAddress(d store.Domain) (string, error) {
return h.store.DomainDMARCRua(d, h.hostedDMARCAddress(d.Name))
}
func domainNameSet(domains []store.Domain) map[string]bool {
m := make(map[string]bool, len(domains))
for _, d := range domains {
+11 -132
View File
@@ -1,13 +1,10 @@
package handlers
import (
"context"
"errors"
"net/http"
"strings"
"time"
"github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/validate"
"github.com/mixeme/selfpost/internal/web/view"
@@ -15,22 +12,20 @@ import (
)
// Account is the signed-in user's own half of what used to be one Settings
// page: who they are to the panel (username, e-mail), their password, and their
// default DMARC report address. Every role has it. Each part is its own form
// and its own POST, so changing one never asks for the others.
// page: who they are to the panel (username, e-mail) and their password. Every
// role has it. Each part is its own form and its own POST, so changing one
// never asks for the others.
//
// The e-mail is the user's — what the panel writes to. The DMARC default is a
// separate choice that may use it: a domain whose report address is "this
// user's default" resolves to whatever is chosen here (store.DMARCDefault).
// The e-mail is the user's — what the panel writes to. A domain's DMARC report
// address is set in the domain's settings, where a button fills it with this
// e-mail; nothing here refers to a domain.
// accountForm is what the page shows back: the stored values, or the ones just
// submitted when a form is re-rendered with an error.
type accountForm struct {
Err string
Username string
Email string
DMARCMode string
DMARCAddress string
Err string
Username string
Email string
}
func (h *Handlers) accountUser(w http.ResponseWriter, r *http.Request) (store.User, bool) {
@@ -49,7 +44,7 @@ func (h *Handlers) accountUser(w http.ResponseWriter, r *http.Request) (store.Us
}
func formOf(u store.User) accountForm {
return accountForm{Username: u.Username, Email: u.Email, DMARCMode: u.DMARCDefaultMode, DMARCAddress: u.DMARCDefaultAddress}
return accountForm{Username: u.Username, Email: u.Email}
}
// HandleAccount shows the Account page.
@@ -62,56 +57,18 @@ func (h *Handlers) HandleAccount(w http.ResponseWriter, r *http.Request) {
}
func (h *Handlers) renderAccount(w http.ResponseWriter, r *http.Request, status int, u store.User, f accountForm) {
hosted := h.dmarc != nil && h.cfg.DMARCEnabled
page := view.NewAccount(h.shellMeta(r), string(u.Role), hosted, u.Email)
page := view.NewAccount(h.shellMeta(r), string(u.Role))
page.Username, page.Email = f.Username, f.Email
page.DMARCSelected, page.DMARCAddress = f.DMARCMode, f.DMARCAddress
page.WithResult(accountFlash(r), f.Err)
page.WithDomainUse(h.domainsFollowing(r, u))
// The stored default, not the one being typed: the authorization record
// belongs to the address domains are actually told to publish.
def := u.DMARCDefault()
if addr := def.Resolve(""); addr != "" && def.Mode != store.DMARCDefaultHosted {
if hub := dnscheck.EmailDomain(addr); hub != "" {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
ra := h.dns.ReportAuth(ctx, hub)
cancel()
page.WithAuthorization(dnscheck.ReportAuthRecordName(hub), dnscheck.ReportAuthExample(),
string(ra.Status), ra.Detail, ra.Records)
}
}
h.view.Render(w, status, "account", page)
}
// domainsFollowing counts the outbound domains the user reaches and how many of
// them take their report address from this user's default.
func (h *Handlers) domainsFollowing(r *http.Request, u store.User) (following, total int) {
p, ok := h.principal(r)
if !ok {
return 0, 0
}
domains, err := h.assignedDomains(p)
if err != nil {
logf("panel: account: list domains: %v", err)
return 0, 0
}
for _, d := range domains {
if d.DMARCRuaUserID.Valid && d.DMARCRuaUserID.Int64 == u.ID {
following++
}
}
return following, len(domains)
}
func accountFlash(r *http.Request) string {
switch r.URL.Query().Get("done") {
case "profile":
return "Profile saved."
case "password":
return "Password changed. Any other signed-in sessions were signed out."
case "dmarc":
return "Default report address saved."
}
return ""
}
@@ -155,9 +112,6 @@ func (h *Handlers) HandleAccountProfile(w http.ResponseWriter, r *http.Request)
fail(http.StatusInternalServerError, "Could not save the profile. Please check the logs and try again.")
return
}
// The e-mail is the report address of every domain that follows this
// user's "my account e-mail" default.
h.resyncAfterEmailChange(u, f.Email)
logf("panel: user %d profile updated (username: %t, e-mail: %t)", u.ID, f.Username != u.Username, f.Email != u.Email)
http.Redirect(w, r, "/account?done=profile", http.StatusSeeOther)
}
@@ -215,78 +169,3 @@ func (h *Handlers) HandleAccountPassword(w http.ResponseWriter, r *http.Request)
logf("panel: user %d changed their password", u.ID)
http.Redirect(w, r, "/account?done=password", http.StatusSeeOther)
}
// HandleAccountDMARC saves the user's default report address: SelfPost hosted,
// the account e-mail, another address, or none.
func (h *Handlers) HandleAccountDMARC(w http.ResponseWriter, r *http.Request) {
u, ok := h.accountUser(w, r)
if !ok {
return
}
f := formOf(u)
fail := func(status int, msg string) {
f.Err = msg
h.renderAccount(w, r, status, u, f)
}
if err := r.ParseForm(); err != nil {
fail(http.StatusBadRequest, "Invalid form submission.")
return
}
f.DMARCMode = strings.TrimSpace(r.PostFormValue("dmarc_default"))
f.DMARCAddress = strings.TrimSpace(r.PostFormValue("dmarc_default_address"))
switch f.DMARCMode {
case store.DMARCDefaultNone:
case store.DMARCDefaultHosted:
if h.dmarc == nil || !h.dmarc.Enabled() {
fail(http.StatusBadRequest, "SelfPost-hosted reports are not enabled on this server.")
return
}
case store.DMARCDefaultAccount:
if u.Email == "" {
fail(http.StatusBadRequest, "Your account has no e-mail yet. Save one under Profile first, or choose another address.")
return
}
case store.DMARCDefaultCustom:
if f.DMARCAddress == "" {
fail(http.StatusBadRequest, "Enter the report address or choose another option.")
return
}
if err := validate.Email(f.DMARCAddress); err != nil {
fail(http.StatusBadRequest, err.Error())
return
}
default:
fail(http.StatusBadRequest, "Choose where your DMARC reports go.")
return
}
if err := h.store.SetDMARCDefault(u.ID, f.DMARCMode, f.DMARCAddress); err != nil {
logf("panel: account: set dmarc default of user %d: %v", u.ID, err)
fail(http.StatusInternalServerError, "Could not save the report address. Please check the logs and try again.")
return
}
h.resyncDMARC("default report address")
logf("panel: user %d set their default report address to %q", u.ID, f.DMARCMode)
http.Redirect(w, r, "/account?done=dmarc", http.StatusSeeOther)
}
// resyncAfterEmailChange is what changing a user's account e-mail does for
// DMARC: the address is the report address of every domain that follows a user
// whose default is "my account e-mail", so the ingest allow-list is rebuilt. It
// is the one path both the Account page and the user form take.
func (h *Handlers) resyncAfterEmailChange(u store.User, newEmail string) {
if newEmail != u.Email && u.DMARCDefaultMode == store.DMARCDefaultAccount {
h.resyncDMARC("account e-mail")
}
}
// resyncDMARC rebuilds the ingest allow-list after something that changes
// where a domain's reports go. A failure is logged, not shown: the setting is
// saved, and the next resync picks it up.
func (h *Handlers) resyncDMARC(why string) {
if h.dmarc == nil || !h.dmarc.Enabled() {
return
}
if err := h.dmarc.Resync(); err != nil {
logf("panel: dmarc resync after %s change: %v", why, err)
}
}
+15 -84
View File
@@ -12,30 +12,34 @@ import (
// Account is every user's own page: each part is its own form and its own
// POST, and none of them touches what another saves.
func TestAccountPageShowsThreeForms(t *testing.T) {
func TestAccountPageShowsTwoForms(t *testing.T) {
h, _ := settingsServer(t)
out := getBody(t, h.HandleAccount, "/account")
for _, want := range []string{
`action="/account/profile"`, `name="email"`,
`action="/account/password"`, `name="current_password"`,
`action="/account/dmarc"`, `name="dmarc_default"`,
} {
if !strings.Contains(out, want) {
t.Errorf("account page missing %q", want)
}
}
for _, gone := range []string{`name="send_log_retention_days"`, `value="hosted"`} {
// A domain's report address is not an Account setting any more.
for _, gone := range []string{`name="send_log_retention_days"`, `/account/dmarc`, `name="dmarc_default"`, `<h2>DMARC reports</h2>`, `Report authorization`} {
if strings.Contains(out, gone) {
t.Errorf("account page shows %q (instance setting, or hosted reports with ingest off)", gone)
t.Errorf("account page shows %q", gone)
}
}
}
// The profile form saves the name and the e-mail without asking for the
// password and without changing the password or the DMARC default.
// password and without changing the password or any domain's report address.
func TestAccountProfile(t *testing.T) {
h, _ := settingsServer(t)
before, _ := h.store.GetUser(globalPrincipal.ID)
d, _ := h.store.AddDomain("example.com", "mail")
if err := h.store.SetDomainDMARCAddress(d.ID, "reports@hub.example"); err != nil {
t.Fatal(err)
}
rec := postFormAs(h.HandleAccountProfile, globalPrincipal, "/account/profile", nil,
url.Values{"username": {"operator"}, "email": {"mix@example.org"}})
@@ -46,8 +50,11 @@ func TestAccountProfile(t *testing.T) {
if u.Username != "operator" || u.Email != "mix@example.org" {
t.Errorf("profile = %q / %q", u.Username, u.Email)
}
if u.PasswordHash != before.PasswordHash || u.DMARCDefaultMode != store.DMARCDefaultNone {
t.Errorf("saving the profile changed the password or the DMARC default (%q)", u.DMARCDefaultMode)
if u.PasswordHash != before.PasswordHash {
t.Error("saving the profile changed the password")
}
if got, _ := h.store.GetDomain(d.ID); got.DMARCRua != "reports@hub.example" {
t.Errorf("saving the profile moved a domain's report address to %q", got.DMARCRua)
}
for _, bad := range []url.Values{
@@ -97,62 +104,6 @@ func TestAccountPassword(t *testing.T) {
}
}
// The default report address is the user's choice between four modes; the
// address field belongs to "another address" alone.
func TestAccountDMARCDefault(t *testing.T) {
h, _ := settingsServer(t)
save := func(form url.Values) (int, store.User) {
rec := postFormAs(h.HandleAccountDMARC, globalPrincipal, "/account/dmarc", nil, form)
u, _ := h.store.GetUser(globalPrincipal.ID)
return rec.Code, u
}
// "My account e-mail" needs an e-mail.
if code, u := save(url.Values{"dmarc_default": {"account"}}); code != http.StatusBadRequest || u.DMARCDefaultMode != store.DMARCDefaultNone {
t.Errorf("account e-mail without an e-mail = %d, mode %q", code, u.DMARCDefaultMode)
}
// Hosted needs report ingest, which this server has off.
if code, u := save(url.Values{"dmarc_default": {"hosted"}}); code != http.StatusBadRequest || u.DMARCDefaultMode != store.DMARCDefaultNone {
t.Errorf("hosted with ingest off = %d, mode %q", code, u.DMARCDefaultMode)
}
for _, bad := range []url.Values{
{"dmarc_default": {"custom"}},
{"dmarc_default": {"custom"}, "dmarc_default_address": {"nope"}},
{"dmarc_default": {"profile"}},
{},
} {
if code, _ := save(bad); code != http.StatusBadRequest {
t.Errorf("%v = %d, want 400", bad, code)
}
}
code, u := save(url.Values{"dmarc_default": {"custom"}, "dmarc_default_address": {"dmarc@hub.example"}})
if code != http.StatusSeeOther || u.DMARCDefaultMode != store.DMARCDefaultCustom || u.DMARCDefaultAddress != "dmarc@hub.example" {
t.Errorf("another address = %d, %q %q", code, u.DMARCDefaultMode, u.DMARCDefaultAddress)
}
// A domain that follows this user now resolves to it.
d, _ := h.store.AddDomain("example.com", "mail")
h.followCreator(d.ID, globalPrincipal.ID)
d, _ = h.store.GetDomain(d.ID)
if rua, err := h.domainReportAddress(d); err != nil || rua != "dmarc@hub.example" {
t.Errorf("a following domain reports to %q, %v", rua, err)
}
if rec := postFormAs(h.HandleAccountProfile, globalPrincipal, "/account/profile", nil, url.Values{"username": {"admin"}, "email": {"mix@example.org"}}); rec.Code != http.StatusSeeOther {
t.Fatalf("save e-mail = %d", rec.Code)
}
code, u = save(url.Values{"dmarc_default": {"account"}, "dmarc_default_address": {"left-over@hub.example"}})
if code != http.StatusSeeOther || u.DMARCDefaultMode != store.DMARCDefaultAccount || u.DMARCDefaultAddress != "" {
t.Errorf("my account e-mail = %d, %q %q", code, u.DMARCDefaultMode, u.DMARCDefaultAddress)
}
if rua, _ := h.domainReportAddress(d); rua != "mix@example.org" {
t.Errorf("the following domain reports to %q, want the account e-mail", rua)
}
if code, u = save(url.Values{"dmarc_default": {"none"}}); code != http.StatusSeeOther || u.DMARCDefaultMode != store.DMARCDefaultNone {
t.Errorf("none = %d, %q", code, u.DMARCDefaultMode)
}
}
// A domain administrator has the same Account page; it is not a Server page.
func TestAccountIsOpenToADomainAdministrator(t *testing.T) {
h, _ := settingsServer(t)
@@ -178,7 +129,7 @@ func TestAccountPageIsDrawnInTheShell(t *testing.T) {
out := getBody(t, h.HandleAccount, "/account")
for _, want := range []string{
`class="navbar is-primary"`, `Signed in as admin · global`, `<h1 class="title is-3">Account</h1>`,
`id="dmarc"`, `href="/static/panel.css"`,
`href="/static/panel.css"`,
} {
if !strings.Contains(out, want) {
t.Errorf("account page missing %q", want)
@@ -222,7 +173,6 @@ func TestAccountResultsAreTheFlash(t *testing.T) {
for done, want := range map[string]string{
"profile": "Profile saved.",
"password": "Password changed.",
"dmarc": "Default report address saved.",
} {
out := getBody(t, h.HandleAccount, "/account?done="+done)
if !strings.Contains(out, `notification is-success is-light`) || !strings.Contains(out, want) {
@@ -247,22 +197,3 @@ func TestAccountPageForADomainAdministrator(t *testing.T) {
}
}
}
// The default address needs an authorization record when it is on another
// domain; with "no reports" nothing is asked of DNS and no record is drawn. The
// count of following domains is the user's own.
func TestAccountReportAuthorizationFollowsTheStoredDefault(t *testing.T) {
h, _ := settingsServer(t)
d, _ := h.store.AddDomain("example.com", "mail")
h.followCreator(d.ID, globalPrincipal.ID)
if _, err := h.store.AddDomain("other.example", "mail"); err != nil {
t.Fatal(err)
}
out := getBody(t, h.HandleAccount, "/account")
if strings.Contains(out, "Report authorization") {
t.Error("an authorization record for a default of no reports")
}
if !strings.Contains(out, "1 of 2 now") {
t.Errorf("the domains following the default are not counted:\n%s", out)
}
}
+32 -85
View File
@@ -71,12 +71,7 @@ func (h *Handlers) renderDomain(w http.ResponseWriter, r *http.Request, status i
// What DNS actually publishes for the domain today, checked against the key
// this server signs with.
reportEmail, err := h.domainReportAddress(d)
if err != nil {
logf("panel: domain %d: dmarc report address: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
reportEmail := d.DMARCRua
dns, srv := h.domainDNS(d, record, reportEmail, false)
records := []view.Record{
view.DKIMRecord(d.DKIMSelector, record.Name, record.Value, dnsCheck(dns.DKIM)),
@@ -88,7 +83,7 @@ func (h *Handlers) renderDomain(w http.ResponseWriter, r *http.Request, status i
}
records = append(records, view.DMARCRecord(view.DMARCRecordInput{
Host: dnscheck.DMARCRecordName(d.Name), Value: dnscheck.DMARCExample(reportEmail), Check: dnsCheck(dns.DMARC),
Source: h.reportSource(d, reportEmail), SettingsHref: view.DomainHref(d.ID) + "/settings", ReportsHref: reports,
Source: h.reportSource(d), SettingsHref: view.DomainHref(d.ID) + "/settings", ReportsHref: reports,
SameDomain: reportEmail != "" && strings.EqualFold(dnscheck.EmailDomain(reportEmail), d.Name) &&
!(h.cfg.DMARCEnabled && dmarc.IsHostedOnHostname(reportEmail, h.cfg.Hostname)),
}))
@@ -112,21 +107,14 @@ func dnsCheck(r dnscheck.Result) view.DNSCheck {
return view.DNSCheck{Status: string(r.Status), Detail: r.Detail, Found: r.Records}
}
// reportSource says in words where a domain's DMARC reports go: the default of
// the user it follows, the hosted address, an address of its own, or nowhere.
func (h *Handlers) reportSource(d store.Domain, reportEmail string) string {
switch {
case d.DMARCRuaUserID.Valid:
if def, err := h.store.GetDMARCDefault(d.DMARCRuaUserID.Int64); err == nil && def.Username != "" {
return def.Username + "'s default"
}
return "a user's default"
case reportEmail == "":
return "none"
case strings.EqualFold(reportEmail, h.hostedDMARCAddress(d.Name)):
return "SelfPost hosted"
// reportSource says in words where a domain's DMARC reports go, for the note
// of its DMARC record: the hosted address or the one typed for the domain,
// written out, or "no reports".
func (h *Handlers) reportSource(d store.Domain) string {
if d.DMARCRua == "" {
return "no reports"
}
return "a custom address"
return d.DMARCRua
}
// outAppRow is one line of the Applications table of a domain.
@@ -160,7 +148,8 @@ func (h *Handlers) outAppRow(d store.Domain, a store.Application, retention int)
}
return view.OutAppRow{
Login: a.Login, Senders: senders, Activity: view.FormatActivity(stats.Total, stats.PeakPerHour), Limits: limits,
Edit: fmt.Sprintf("%s/applications/%d", view.DomainHref(d.ID), a.ID),
Edit: fmt.Sprintf("%s/applications/%d", view.DomainHref(d.ID), a.ID),
Actions: view.AppRowActions(d.ID, a.ID, a.Login),
}, nil
}
@@ -214,76 +203,40 @@ func (h *Handlers) renderDomainSettings(w http.ResponseWriter, r *http.Request,
rate.Computed = intOrBlank(rl.MaxMessages)
}
options, selected, custom, help := h.reportAddressForm(r, d)
options, selected, custom, mine := h.reportAddressForm(r, d)
if sv.RuaMode != "" {
selected, custom = sv.RuaMode, sv.RuaCustom
}
page := view.NewOutDomainSettings(h.shellMeta(r), d.ID, d.Name, d.AppCount, p.IsGlobal()).
WithReportAddress(options, selected, custom, help).
WithReportAddress(options, selected, custom, mine).
WithExport(validate.MinSecretFilePasswordLen).
WithRateLimit(rate).
WithResult("", sv.Err)
h.view.Render(w, status, "out-domain-settings", page)
}
// reportAddressForm is the choices of the report-address select for a domain
// and the signed-in user. "inherit" makes the domain follow the default of the
// user who saves the form (HandleDomainDMARC), so what the option says depends
// on whom the domain follows now: the user themselves, someone else, or nobody.
// A domain that follows someone else also offers "keep" — that user's default,
// selected — so saving the form untouched does not take the domain over.
func (h *Handlers) reportAddressForm(r *http.Request, d store.Domain) (options []view.Option, selected, custom string, help view.Text) {
hosted := h.hostedDMARCAddress(d.Name)
me, _ := h.principal(r)
mine, _ := h.store.GetDMARCDefault(me.ID)
address := func(a string) string {
if a == "" {
return "no report address yet"
}
return a
// reportAddressForm is the report-address form of a domain: the three choices
// of the select, the one the stored address stands for, the address typed for
// the domain (only in the typed choice) and the button that fills it with the
// signed-in user's profile e-mail ("" when the profile has none). SelfPost
// hosted is offered only while hosted reports are enabled.
func (h *Handlers) reportAddressForm(r *http.Request, d store.Domain) (options []view.Option, selected, custom, mine string) {
options = append(options, view.Option{Value: ruaNone, Label: "No reports"})
if hosted := h.hostedDMARCAddress(d.Name); hosted != "" {
options = append(options, view.Option{Value: ruaHosted, Label: "SelfPost hosted — " + hosted})
}
myAddr := address(mine.Resolve(hosted))
options = append(options, view.Option{Value: ruaCustom, Label: "A specific address"})
help = view.Rich("A default belongs to a user and is set under their ", view.Link("/account#dmarc", "Account"),
"; a domain follows one named user, so two people sharing a domain never pull it two ways. Changing this changes the DMARC record to publish.")
inherit := view.Option{Value: "inherit", Label: "My default — " + myAddr}
followsOther := d.DMARCRuaUserID.Valid && d.DMARCRuaUserID.Int64 != me.ID
switch {
case followsOther:
inherit.Label = "My default instead — " + myAddr + " (you are " + me.Username + ")"
keep := view.Option{Value: "keep", Label: "Another user's default"}
if other, err := h.store.GetDMARCDefault(d.DMARCRuaUserID.Int64); err == nil {
keep.Label = other.Username + "'s default — " + address(other.Resolve(hosted))
selected = h.reportChoice(d)
if selected == ruaCustom {
custom = d.DMARCRua
}
if p, ok := h.principal(r); ok {
if u, err := h.store.GetUser(p.ID); err == nil {
mine = u.Email
}
options = append(options, keep)
case d.DMARCRuaUserID.Valid:
inherit.Label = me.Username + "'s default — " + myAddr
options = append(options, inherit)
default:
options = append(options, inherit)
}
if hosted != "" {
options = append(options, view.Option{Value: "hosted", Label: "SelfPost hosted (" + hosted + ")"})
}
// The mockup's order: whose default is followed now, hosted, "mine instead".
if followsOther {
options = append(options, inherit)
}
options = append(options, view.Option{Value: "none", Label: "No aggregate reports"}, view.Option{Value: "custom", Label: "Custom address"})
switch {
case followsOther:
selected = "keep"
case d.DMARCRuaUserID.Valid:
selected = "inherit"
case d.DMARCRua == "":
selected = "none"
case hosted != "" && strings.EqualFold(d.DMARCRua, hosted):
selected = "hosted"
default:
selected, custom = "custom", d.DMARCRua
}
return options, selected, custom, help
return options, selected, custom, mine
}
// formatBound writes a multiplier bound the short way: "1.5", "5".
@@ -320,13 +273,7 @@ func (h *Handlers) HandleDomainDNSRecheck(w http.ResponseWriter, r *http.Request
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
reportEmail, err := h.domainReportAddress(d)
if err != nil {
logf("panel: domain %d: dmarc report address: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
h.domainDNS(d, record, reportEmail, true)
h.domainDNS(d, record, d.DMARCRua, true)
http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?rechecked=1", d.ID), http.StatusSeeOther)
}
@@ -263,3 +263,42 @@ func TestRegeneratedPasswordIsNotCached(t *testing.T) {
t.Error("the response does not show the new password")
}
}
// New password and Delete are in the application's row of the domain page, for
// whoever may edit the application: each is a POST form aimed at the
// application's own handler and asks first. A domain administrator of another
// domain gets a 404 from the same targets.
func TestApplicationRowCarriesNewPasswordAndDelete(t *testing.T) {
s := newAppStand(t)
a, _, err := s.h.apps.CreateWithSettings(s.d.ID, "prod-server", app.Settings{Mode: store.AddressModeWildcard}, nil)
if err != nil {
t.Fatal(err)
}
row := `<td class="sp-actions"><a href="/outbound/domains/1/applications/` + idStr(a.ID) + `">Edit</a>` +
`<form method="post" action="/outbound/domains/1/applications/` + idStr(a.ID) + `/password" data-confirm="Generate a new password for prod-server? The current password stops working immediately."><button type="submit">New password</button></form>` +
`<form method="post" action="/outbound/domains/1/applications/` + idStr(a.ID) + `/delete" data-confirm="Delete application prod-server? Its credentials stop working immediately."><button type="submit" class="has-text-danger">Delete</button></form></td>`
has(t, "domain page", pageOf(t, s.h.HandleDomainDetail, "/outbound/domains/1"), row,
`New password and Delete ask for confirmation; the old password stops working at once.`)
ops := domainAdmin(t, s.h.store, "ops", s.d.ID)
has(t, "domain page for its administrator", send(s.h.HandleDomainDetail, &ops, "GET", "/outbound/domains/1", s.paths, nil).Body.String(), row)
// The row's targets are the application page's own handlers.
paths := map[string]string{"id": idStr(s.d.ID), "aid": idStr(a.ID)}
if rec := postFormAs(s.h.HandleRegenPassword, ops, "/outbound/domains/1/applications/1/password", paths, url.Values{}); rec.Code != http.StatusOK ||
rec.Header().Get("Cache-Control") != "no-store" || !strings.Contains(rec.Body.String(), s.sasl["prod-server"]) {
t.Errorf("the row's New password as a domain administrator = %d, Cache-Control %q", rec.Code, rec.Header().Get("Cache-Control"))
}
stranger := domainAdmin(t, s.h.store, "stranger")
for name, h := range map[string]http.HandlerFunc{"password": s.h.HandleRegenPassword, "delete": s.h.HandleDeleteApplication} {
if rec := postFormAs(h, stranger, "/outbound/domains/1/applications/1/"+name, paths, url.Values{}); rec.Code != http.StatusNotFound {
t.Errorf("%s as the administrator of another domain = %d, want 404", name, rec.Code)
}
}
if rec := postFormAs(s.h.HandleDeleteApplication, ops, "/outbound/domains/1/applications/1/delete", paths, url.Values{}); rec.Code != http.StatusSeeOther {
t.Fatalf("the row's Delete as a domain administrator = %d", rec.Code)
}
if _, err := s.h.store.GetApplicationByLogin("prod-server"); err == nil {
t.Error("the application is still there after Delete")
}
}
+2 -6
View File
@@ -273,12 +273,8 @@ func (h *Handlers) HandleImportDomain(w http.ResponseWriter, r *http.Request) {
h.renderBackupPage(w, r, status, msg)
return
}
// An export without an address of its own followed someone's default on the
// server it came from; here it follows whoever imports it.
if exp.DMARCRua == nil {
if p, ok := h.principal(r); ok {
h.followCreator(d.ID, p.ID)
}
if exp.DMARCRua != "" {
h.resyncDMARC("domain import")
}
http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?imported=1", d.ID), http.StatusSeeOther)
}
+47 -31
View File
@@ -6,10 +6,16 @@ import (
"strings"
"github.com/mixeme/selfpost/internal/dmarc"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/validate"
"github.com/mixeme/selfpost/internal/web/view"
)
// HandleDomainDMARC saves per-domain DMARC rua= settings.
// HandleDomainDMARC saves where a domain's aggregate reports (rua=) go. The
// form offers exactly three choices: "none", "hosted" (the address SelfPost
// derives for the domain, offered only while hosted reports are enabled) and
// "custom" (an address typed for the domain). What is stored is the address
// alone; "" is no reports.
func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", "POST")
@@ -25,9 +31,6 @@ func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) {
return
}
// "inherit" makes the domain follow the default of the user who chose it;
// every other mode gives the domain an address of its own ("" = none).
follow := false
rua := ""
mode := strings.TrimSpace(r.PostFormValue("dmarc_rua_mode"))
// A refusal shows the form again with the choice that was made.
@@ -35,30 +38,19 @@ func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) {
h.renderDomainSettings(w, r, status, d, settingsView{Err: msg, RuaMode: mode, RuaCustom: strings.TrimSpace(r.PostFormValue("dmarc_rua_email"))})
}
switch mode {
case "keep":
// The domain follows another user's default and the form left it so.
// Anything else under this value is a stale or forged form.
if p, ok := h.principal(r); !ok || !d.DMARCRuaUserID.Valid || d.DMARCRuaUserID.Int64 == p.ID {
refuse(http.StatusBadRequest, "Choose how aggregate reports are addressed for this domain.")
return
}
http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?dmarc=1", d.ID), http.StatusSeeOther)
return
case "inherit":
follow = true
case "none":
case "custom":
case ruaNone:
case ruaCustom:
email := strings.TrimSpace(r.PostFormValue("dmarc_rua_email"))
if err := validate.Email(email); err != nil {
refuse(http.StatusBadRequest, err.Error())
return
}
if email == "" {
refuse(http.StatusBadRequest, "Enter a custom report address or choose another mode.")
refuse(http.StatusBadRequest, "Enter the report address or choose another option.")
return
}
rua = email
case "hosted":
case ruaHosted:
if h.dmarc == nil || !h.dmarc.Enabled() {
refuse(http.StatusBadRequest, "SelfPost-hosted reports are not enabled on this server.")
return
@@ -69,22 +61,46 @@ func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) {
return
}
var err error
if p, ok := h.principal(r); follow && ok {
err = h.store.SetDomainDMARCUser(d.ID, p.ID)
} else {
err = h.store.SetDomainDMARCAddress(d.ID, rua)
}
if err != nil {
if err := h.store.SetDomainDMARCAddress(d.ID, rua); err != nil {
logf("panel: domain %d: save dmarc rua: %v", d.ID, err)
refuse(http.StatusInternalServerError, "Could not save DMARC settings. Please check the logs and try again.")
return
}
if h.dmarc != nil && h.dmarc.Enabled() {
if err := h.dmarc.Resync(); err != nil {
logf("panel: domain %d: dmarc resync: %v", d.ID, err)
}
}
h.resyncDMARC("domain report address")
h.dns.Forget(d.Name)
http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?dmarc=1", d.ID), http.StatusSeeOther)
}
// The three choices of a domain's report address, the values of the select.
const (
ruaNone = view.ReportNone
ruaHosted = view.ReportHosted
ruaCustom = view.ReportCustom
)
// reportChoice says which of the three choices a domain's stored address is:
// none for "", hosted for the address SelfPost derives for the domain (only
// while hosted reports are enabled — otherwise it is just an address), custom
// for anything else.
func (h *Handlers) reportChoice(d store.Domain) string {
hosted := h.hostedDMARCAddress(d.Name)
switch {
case d.DMARCRua == "":
return ruaNone
case hosted != "" && strings.EqualFold(d.DMARCRua, hosted):
return ruaHosted
}
return ruaCustom
}
// resyncDMARC rebuilds the ingest allow-list after something that changes
// where a domain's reports go. A failure is logged, not shown: the setting is
// saved, and the next resync picks it up.
func (h *Handlers) resyncDMARC(why string) {
if h.dmarc == nil || !h.dmarc.Enabled() {
return
}
if err := h.dmarc.Resync(); err != nil {
logf("panel: dmarc resync after %s change: %v", why, err)
}
}
+260 -66
View File
@@ -4,10 +4,13 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"regexp"
"strings"
"testing"
"github.com/mixeme/selfpost/internal/dmarc"
"github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
)
@@ -24,78 +27,269 @@ func postFormAs(h http.HandlerFunc, p auth.Principal, target string, pathValues
return rec
}
// The domain form's "Same as Settings" is, on the 2.0 schema, a domain that
// follows the default of the user who chose it; every other choice gives the
// domain an address of its own and stops it following anyone.
func TestDomainDMARCFollowsTheUserWhoChoseInherit(t *testing.T) {
h, _ := settingsServer(t)
h.domains = domain.NewService(h.store, nil, nil, "selfpost")
d, err := h.store.AddDomain("example.com", "selfpost")
if err != nil {
t.Fatalf("AddDomain: %v", err)
}
admin, err := h.store.GetUserByUsername("admin")
if err != nil {
t.Fatalf("GetUserByUsername: %v", err)
}
p := auth.Principal{ID: admin.ID, Username: "admin", Role: auth.RoleGlobal}
if err := h.store.UpdateUser(admin.ID, "admin", admin.PasswordHash, "mix@example.org"); err != nil {
t.Fatalf("UpdateUser: %v", err)
}
if err := h.store.SetDMARCDefault(admin.ID, store.DMARCDefaultAccount, ""); err != nil {
t.Fatalf("SetDMARCDefault: %v", err)
}
save := func(values url.Values) store.Domain {
t.Helper()
rec := postFormAs(h.HandleDomainDMARC, p, "/outbound/domains/1/settings/reports", map[string]string{"id": "1"}, values)
if rec.Code != http.StatusSeeOther {
t.Fatalf("POST %v = %d, want 303:\n%s", values, rec.Code, rec.Body.String())
}
got, err := h.store.GetDomain(d.ID)
if err != nil {
t.Fatalf("GetDomain: %v", err)
}
return got
}
got := save(url.Values{"dmarc_rua_mode": {"inherit"}})
if !got.DMARCRuaUserID.Valid || got.DMARCRuaUserID.Int64 != admin.ID {
t.Fatalf("inherit: the domain follows %v, want user %d", got.DMARCRuaUserID, admin.ID)
}
if rua, err := h.domainReportAddress(got); err != nil || rua != "mix@example.org" {
t.Fatalf("inherit resolves to %q, %v; want the user's account e-mail", rua, err)
}
got = save(url.Values{"dmarc_rua_mode": {"custom"}, "dmarc_rua_email": {"dmarc@hub.example"}})
if got.DMARCRuaUserID.Valid || got.DMARCRua != "dmarc@hub.example" {
t.Fatalf("custom: %q / follows %v", got.DMARCRua, got.DMARCRuaUserID)
}
got = save(url.Values{"dmarc_rua_mode": {"none"}})
if got.DMARCRuaUserID.Valid || got.DMARCRua != "" {
t.Fatalf("none: %q / follows %v", got.DMARCRua, got.DMARCRuaUserID)
}
// dmarcRecordingMaps stands in for Postfix: the allow-lists a resync would write.
type dmarcRecordingMaps struct{ got []postfix.DMARCMapsConfig }
func (m *dmarcRecordingMaps) RebuildDMARCMaps(cfg postfix.DMARCMapsConfig) error {
m.got = append(m.got, cfg)
return nil
}
// A new domain takes its report address from whoever created it.
func TestFollowCreator(t *testing.T) {
h, _ := settingsServer(t)
d, err := h.store.AddDomain("example.com", "selfpost")
// hostedStand is an app stand whose server receives hosted DMARC reports.
func hostedStand(t *testing.T) (appStand, *dmarcRecordingMaps) {
t.Helper()
s := newAppStand(t)
maps := &dmarcRecordingMaps{}
s.h.cfg.DMARCEnabled, s.h.cfg.Hostname = true, "mail.example.org"
s.h.dmarc = dmarc.NewService(s.h.store, maps, "mail.example.org", true)
return s, maps
}
const hostedForExample = "dmarc-reports+example.org@mail.example.org"
// hostedEscaped is that address as html/template writes it into a page.
const hostedEscaped = "dmarc-reports&#43;example.org@mail.example.org"
// saveReports posts the report-address form of domain 1 as the global
// administrator.
func (s appStand) saveReports(form url.Values) *httptest.ResponseRecorder {
return postFormAs(s.h.HandleDomainDMARC, globalPrincipal, "/outbound/domains/1/settings/reports", s.paths, form)
}
func (s appStand) reportAddress(t *testing.T) string {
t.Helper()
d, err := s.h.store.GetDomain(s.d.ID)
if err != nil {
t.Fatalf("AddDomain: %v", err)
t.Fatal(err)
}
h.followCreator(d.ID, globalPrincipal.ID)
got, err := h.store.GetDomain(d.ID)
if err != nil {
t.Fatalf("GetDomain: %v", err)
return d.DMARCRua
}
func contains(list []string, want string) bool {
for _, s := range list {
if s == want {
return true
}
}
if !got.DMARCRuaUserID.Valid || got.DMARCRuaUserID.Int64 != globalPrincipal.ID {
t.Fatalf("the domain follows %v, want its creator (user %d)", got.DMARCRuaUserID, globalPrincipal.ID)
return false
}
// A new domain has no report address: its settings show "No reports" selected,
// and its DMARC record says so.
func TestNewDomainHasNoReports(t *testing.T) {
s, _ := hostedStand(t)
// A second OpenDKIM that reloads nothing: the stand's has no supervisor.
odk := domain.NewOpenDKIM(t.TempDir())
odk.SetReloadHook(func() error { return nil })
if _, err := odk.EnsureKey("example.org", "mail"); err != nil {
t.Fatal(err)
}
// The creator's default is "none" until they choose one, so the new
// domain publishes a policy-only record — as a fresh 1.x install did.
if rua, err := h.domainReportAddress(got); err != nil || rua != "" {
t.Fatalf("resolved %q, %v; want no report address yet", rua, err)
s.h.domains = domain.NewService(s.h.store, odk, s.h.apps, "mail")
rec := postFormAs(s.h.HandleAddDomain, globalPrincipal, "/outbound/domains", nil, url.Values{"name": {"new.example"}})
if rec.Code != http.StatusSeeOther {
t.Fatalf("adding a domain = %d:\n%s", rec.Code, rec.Body.String())
}
domains, _ := s.h.store.ListDomains()
var added store.Domain
for _, d := range domains {
if d.Name == "new.example" {
added = d
}
}
if added.ID == 0 || added.DMARCRua != "" {
t.Fatalf("a new domain reports to %q (%+v), want no reports", added.DMARCRua, added)
}
body := send(s.h.HandleDomainSettings, &globalPrincipal, "GET", "/outbound/domains/2/settings", map[string]string{"id": idStr(added.ID)}, nil).Body.String()
has(t, "settings of a new domain", body, `<option value="none" selected>No reports</option>`)
lacks(t, "settings of a new domain", body, `<option value="hosted" selected>`, `<option value="custom" selected>`)
// The domain the stand started with has none either.
has(t, "domain page", pageOf(t, s.h.HandleDomainDetail, "/outbound/domains/1"),
`Report address: <a href="/outbound/domains/1/settings#reports">no reports</a>`)
}
// Each of the three choices is saved, comes back selected, and is what the
// DMARC record names. Hosted is the address SelfPost derives for the domain.
func TestDomainReportAddressThreeChoices(t *testing.T) {
s, maps := hostedStand(t)
settings := func() string {
return pageOf(t, s.h.HandleDomainSettings, "/outbound/domains/1/settings")
}
// Offered in the order of the mockup, hosted because the server receives it.
has(t, "settings", settings(),
`<option value="none" selected>No reports</option>`,
`<option value="hosted">SelfPost hosted — `+hostedEscaped+`</option>`,
`<option value="custom">A specific address</option>`)
if rec := s.saveReports(url.Values{"dmarc_rua_mode": {"hosted"}}); rec.Code != http.StatusSeeOther {
t.Fatalf("hosted = %d:\n%s", rec.Code, rec.Body.String())
}
if got := s.reportAddress(t); got != hostedForExample {
t.Fatalf("hosted stored %q, want %q", got, hostedForExample)
}
has(t, "settings after hosted", settings(), `<option value="hosted" selected>`)
lacks(t, "settings after hosted", settings(), `value="`+hostedForExample+`"`) // the typed field stays empty
has(t, "domain page after hosted", pageOf(t, s.h.HandleDomainDetail, "/outbound/domains/1"),
`Report address: <a href="/outbound/domains/1/settings#reports">`+hostedEscaped+`</a>`,
`rua=mailto:`+hostedEscaped)
// The ingest allow-list was rebuilt for the new address.
if last := maps.got[len(maps.got)-1]; !contains(last.Recipients, hostedForExample) {
t.Errorf("the allow-list after choosing hosted: %v", last.Recipients)
}
if rec := s.saveReports(url.Values{"dmarc_rua_mode": {"custom"}, "dmarc_rua_email": {" reports@hub.example "}}); rec.Code != http.StatusSeeOther {
t.Fatalf("custom = %d:\n%s", rec.Code, rec.Body.String())
}
if got := s.reportAddress(t); got != "reports@hub.example" {
t.Fatalf("custom stored %q", got)
}
has(t, "settings after custom", settings(), `<option value="custom" selected>`, `value="reports@hub.example"`)
has(t, "domain page after custom", pageOf(t, s.h.HandleDomainDetail, "/outbound/domains/1"),
`Report address: <a href="/outbound/domains/1/settings#reports">reports@hub.example</a>`, `rua=mailto:reports@hub.example`,
`<h3>Report authorization</h3>`) // the address is on another domain than the senders
if last := maps.got[len(maps.got)-1]; contains(last.Recipients, hostedForExample) || contains(last.Recipients, "reports@hub.example") {
t.Errorf("the allow-list after choosing another host: %v", last.Recipients)
}
if rec := s.saveReports(url.Values{"dmarc_rua_mode": {"none"}, "dmarc_rua_email": {"left-over@hub.example"}}); rec.Code != http.StatusSeeOther {
t.Fatalf("none = %d:\n%s", rec.Code, rec.Body.String())
}
if got := s.reportAddress(t); got != "" {
t.Fatalf("none stored %q; the typed address belongs to the typed choice alone", got)
}
has(t, "settings after none", settings(), `<option value="none" selected>`)
lacks(t, "settings after none", settings(), `left-over@hub.example`)
}
// Hosted reports are offered, and accepted, only while the server receives
// them; the old follow / keep choices are not accepted any more.
func TestDomainReportAddressRefusals(t *testing.T) {
s := newAppStand(t) // hosted reports are off
body := pageOf(t, s.h.HandleDomainSettings, "/outbound/domains/1/settings")
lacks(t, "settings", body, `value="hosted"`, `SelfPost hosted`, `value="inherit"`, `value="keep"`)
has(t, "settings", body, `<option value="none" selected>No reports</option>`, `<option value="custom">A specific address</option>`)
if err := s.h.store.SetDomainDMARCAddress(s.d.ID, "reports@hub.example"); err != nil {
t.Fatal(err)
}
for _, form := range []url.Values{
{"dmarc_rua_mode": {"hosted"}},
{"dmarc_rua_mode": {"inherit"}},
{"dmarc_rua_mode": {"keep"}},
{"dmarc_rua_mode": {"custom"}},
{"dmarc_rua_mode": {"custom"}, "dmarc_rua_email": {"not an address<b>"}},
{},
} {
if rec := s.saveReports(form); rec.Code != http.StatusBadRequest {
t.Errorf("%v = %d, want 400", form, rec.Code)
}
if got := s.reportAddress(t); got != "reports@hub.example" {
t.Fatalf("%v changed the address to %q", form, got)
}
}
// A refused address comes back in the field, escaped, with its choice.
rec := s.saveReports(url.Values{"dmarc_rua_mode": {"custom"}, "dmarc_rua_email": {`x"><i>@no`}})
has(t, "refusal", rec.Body.String(), `<option value="custom" selected>`, `value="x&#34;&gt;&lt;i&gt;@no"`)
lacks(t, "refusal", rec.Body.String(), `<i>@no`)
}
// Saving the form as it was shown changes nothing, whichever of the three
// states the domain is in.
func TestSavingTheReportFormUntouchedChangesNothing(t *testing.T) {
selected := regexp.MustCompile(`<option value="([a-z]+)" selected>`)
typed := regexp.MustCompile(`name="dmarc_rua_email" type="email" value="([^"]*)"`)
for name, stored := range map[string]string{
"no reports": "", "hosted": hostedForExample, "typed": "reports@hub.example",
} {
s, _ := hostedStand(t)
if err := s.h.store.SetDomainDMARCAddress(s.d.ID, stored); err != nil {
t.Fatal(err)
}
page := pageOf(t, s.h.HandleDomainSettings, "/outbound/domains/1/settings")
form := url.Values{}
if m := selected.FindStringSubmatch(page[strings.Index(page, `name="dmarc_rua_mode"`):]); m != nil {
form.Set("dmarc_rua_mode", m[1])
}
if m := typed.FindStringSubmatch(page); m != nil {
form.Set("dmarc_rua_email", m[1])
}
if form.Get("dmarc_rua_mode") == "" {
t.Fatalf("%s: no choice is selected on the page:\n%s", name, page)
}
if rec := s.saveReports(form); rec.Code != http.StatusSeeOther {
t.Fatalf("%s: saving untouched %v = %d:\n%s", name, form, rec.Code, rec.Body.String())
}
if got := s.reportAddress(t); got != stored {
t.Errorf("%s: an untouched form changed %q to %q", name, stored, got)
}
}
}
// settingsAs is the settings page of domain 1 as the given principal.
func settingsAs(t *testing.T, s appStand, p auth.Principal) string {
t.Helper()
rec := send(s.h.HandleDomainSettings, &p, "GET", "/outbound/domains/1/settings", s.paths, nil)
if rec.Code != http.StatusOK {
t.Fatalf("settings as %s = %d", p.Username, rec.Code)
}
return rec.Body.String()
}
// The button that fills the typed address carries the signed-in user's profile
// e-mail, escaped; without an e-mail there is no button.
func TestFillButtonCarriesTheProfileEmail(t *testing.T) {
s := newAppStand(t)
lacks(t, "settings", settingsAs(t, s, globalPrincipal), `data-fill`, `Use my e-mail`)
admin, _ := s.h.store.GetUser(globalPrincipal.ID)
if err := s.h.store.UpdateUser(admin.ID, admin.Username, admin.PasswordHash, `o'brien&co@example.org`); err != nil {
t.Fatal(err)
}
body := settingsAs(t, s, globalPrincipal)
has(t, "settings", body, `data-fill="rua_email" data-fill-value="o&#39;brien&amp;co@example.org"`, `Use my e-mail`)
lacks(t, "settings", body, `o'brien`)
// It fills; the form's own button saves, so the fill button is not a submit.
has(t, "settings", body, `<button type="button" class="button" data-fill=`)
// A domain administrator is offered their own e-mail, never the global one's.
ops := domainAdmin(t, s.h.store, "ops", s.d.ID)
if got := settingsAs(t, s, ops); strings.Contains(got, "brien") || strings.Contains(got, "data-fill") {
t.Errorf("the co-administrator without an e-mail is offered someone else's:\n%s", got)
}
opsUser, _ := s.h.store.GetUser(ops.ID)
if err := s.h.store.UpdateUser(opsUser.ID, opsUser.Username, opsUser.PasswordHash, "ops@example.org"); err != nil {
t.Fatal(err)
}
has(t, "settings for ops", settingsAs(t, s, ops), `data-fill-value="ops@example.org"`)
}
// Deleting a user leaves every domain's address as it was, and a domain
// administrator never sees another user's name or address in this form.
func TestDeletingAUserLeavesDomainAddressesAndNamesNobody(t *testing.T) {
s, _ := hostedStand(t)
admin, _ := s.h.store.GetUser(globalPrincipal.ID)
if err := s.h.store.UpdateUser(admin.ID, admin.Username, admin.PasswordHash, "boss-private@example.org"); err != nil {
t.Fatal(err)
}
if rec := s.saveReports(url.Values{"dmarc_rua_mode": {"custom"}, "dmarc_rua_email": {"reports@hub.example"}}); rec.Code != http.StatusSeeOther {
t.Fatalf("save = %d", rec.Code)
}
ops := domainAdmin(t, s.h.store, "ops", s.d.ID)
for name, body := range map[string]string{
"settings": settingsAs(t, s, ops),
"domain": send(s.h.HandleDomainDetail, &ops, "GET", "/outbound/domains/1", s.paths, nil).Body.String(),
} {
lacks(t, name+" for the co-administrator", body, `boss-private@example.org`, `admin&#39;s`, `s default`, `Another user`)
}
rec := send(s.h.HandleUserDelete, &globalPrincipal, "POST", "/server/users/"+idStr(ops.ID)+"/delete", map[string]string{"uid": idStr(ops.ID)}, url.Values{})
if rec.Code != http.StatusSeeOther {
t.Fatalf("deleting a user = %d:\n%s", rec.Code, rec.Body.String())
}
if got := s.reportAddress(t); got != "reports@hub.example" {
t.Errorf("deleting a user changed the domain's address to %q", got)
}
confirm := send(s.h.HandleUserDeleteConfirm, &globalPrincipal, "GET", "/server/users/"+idStr(admin.ID)+"/delete", map[string]string{"uid": idStr(admin.ID)}, nil).Body.String()
lacks(t, "delete confirmation", confirm, `report address`, `will have no`)
}
+2 -24
View File
@@ -68,12 +68,7 @@ func (h *Handlers) outDomainRows(domains []store.Domain, retention int, canDelet
logf("panel: domains: domain %d: dkim record: %v", d.ID, err)
return
}
reportEmail, err := h.domainReportAddress(d)
if err != nil {
logf("panel: domains: domain %d: dmarc report address: %v", d.ID, err)
return
}
dns, _ := h.domainDNS(d, record, reportEmail, false)
dns, _ := h.domainDNS(d, record, d.DMARCRua, false)
rows[i].DNS = dnsTags(dns.DKIM.Status, dns.SPF.Status, dns.DMARC.Status)
}()
}
@@ -92,7 +87,7 @@ func dashboardFlash(r *http.Request) string {
// OpenDKIM reload), and redirects to the domain's page so the DNS record to
// publish is shown (product.md).
func (h *Handlers) HandleAddDomain(w http.ResponseWriter, r *http.Request) {
p, ok := h.requireGlobal(w, r)
_, ok := h.requireGlobal(w, r)
if !ok {
return
}
@@ -118,26 +113,9 @@ func (h *Handlers) HandleAddDomain(w http.ResponseWriter, r *http.Request) {
"Could not add the domain. Please check the logs and try again.", raw)
return
}
h.followCreator(d.ID, p.ID)
http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d", d.ID), http.StatusSeeOther)
}
// followCreator makes a new domain take its DMARC report address from the
// default of whoever created it (plan § Account e-mail). The domain exists
// either way: a failure here leaves it with no report address, which its page
// shows and its DMARC form can change.
func (h *Handlers) followCreator(domainID, userID int64) {
if err := h.store.SetDomainDMARCUser(domainID, userID); err != nil {
logf("panel: domain %d: follow user %d's dmarc default: %v", domainID, userID, err)
return
}
if h.dmarc != nil && h.dmarc.Enabled() {
if err := h.dmarc.Resync(); err != nil {
logf("panel: domain %d: dmarc resync: %v", domainID, err)
}
}
}
// HandleDeleteConfirm shows the cascade warning before a domain is removed.
func (h *Handlers) HandleDeleteConfirm(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
@@ -95,7 +95,7 @@ func TestDomainPageShowsRecordsApplicationsAndConnection(t *testing.T) {
has(t, "domain", body,
`<h1 class="title is-3">example.org</h1>`, `class="sp-postmark`, `<h3>DKIM</h3>`, `<h3>SPF</h3>`, `<h3>DMARC</h3>`,
`mail._domainkey.example.org`, `v=DKIM1`, `_dmarc.example.org`, `v=DMARC1; p=none`, `v=spf1 `,
`<td class="sp-mono"><strong>prod-server</strong></td>`, `alerts@<wbr>example.org`, `200 / h`, `2 IPs`,
`<td class="sp-mono sp-nowrap"><strong>prod-server</strong></td>`, `alerts@<wbr>example.org`, `200 / h`, `2 IPs`,
`href="/outbound/domains/1/applications/`+idStr(a.ID)+`">Edit</a>`,
`mail.example.org`, `STARTTLS (submission)`, `action="/outbound/domains/1/dns-recheck"`,
`href="/outbound/log?domain=example.org"`, `href="/outbound/domains/1/settings#reports"`, `href="/outbound/domains/1/delete"`)
@@ -120,60 +120,16 @@ func TestDomainPageWithoutApplicationsSaysSo(t *testing.T) {
lacks(t, "domain", body, `<th>Login</th>`)
}
// The report address form offers what the viewer can choose, and says whose
// default the domain follows.
func TestDomainSettingsReportAddressChoices(t *testing.T) {
// The rest of the settings page, beside the report address (the three choices
// are in handlers_dmarc_test.go).
func TestDomainSettingsPageHasItsBoxes(t *testing.T) {
s := newAppStand(t)
body := pageOf(t, s.h.HandleDomainSettings, "/outbound/domains/1/settings")
has(t, "settings", body, `<h1 class="title is-3">Domain settings</h1>`,
`action="/outbound/domains/1/settings/reports"`, `<option value="inherit">My default — no report address yet</option>`,
`<option value="none" selected>`, `<option value="custom">`,
`action="/outbound/domains/1/settings/reports"`, `<option value="none" selected>No reports</option>`, `<option value="custom">A specific address</option>`,
`action="/outbound/domains/1/settings/ratelimit"`, `action="/outbound/domains/1/settings/export"`,
`name="encrypt" value="1" checked`, `minlength="12"`, `<h2>Delete domain</h2>`, `Removes the DKIM key.`)
lacks(t, "settings", body, `value="hosted"`, `name="clear"`, `ratelimit/recalc`)
// Following the signed-in user's default.
if err := s.h.store.SetDomainDMARCUser(s.d.ID, globalPrincipal.ID); err != nil {
t.Fatal(err)
}
if err := s.h.store.SetDMARCDefault(globalPrincipal.ID, store.DMARCDefaultCustom, "dmarc@hub.example"); err != nil {
t.Fatal(err)
}
body = pageOf(t, s.h.HandleDomainSettings, "/outbound/domains/1/settings")
has(t, "settings", body, `<option value="inherit" selected>admin&#39;s default — dmarc@hub.example</option>`)
// "keep" names nobody when the domain follows the person saving: refused.
if rec := postFormAs(s.h.HandleDomainDMARC, globalPrincipal, "/outbound/domains/1/settings/reports", map[string]string{"id": "1"},
url.Values{"dmarc_rua_mode": {"keep"}}); rec.Code != http.StatusBadRequest {
t.Errorf("keep on a domain that follows the saver = %d, want 400", rec.Code)
}
// Following someone else's: theirs is selected and is what an untouched form
// saves; one's own is offered as the change.
other := domainAdmin(t, s.h.store, "ops", s.d.ID)
if err := s.h.store.SetDMARCDefault(other.ID, store.DMARCDefaultCustom, "ops@hub.example"); err != nil {
t.Fatal(err)
}
if err := s.h.store.SetDomainDMARCUser(s.d.ID, other.ID); err != nil {
t.Fatal(err)
}
body = pageOf(t, s.h.HandleDomainSettings, "/outbound/domains/1/settings")
has(t, "settings", body, `<option value="keep" selected>ops&#39;s default — ops@hub.example</option>`,
`<option value="inherit">My default instead — dmarc@hub.example (you are admin)</option>`)
if rec := postFormAs(s.h.HandleDomainDMARC, globalPrincipal, "/outbound/domains/1/settings/reports", map[string]string{"id": "1"},
url.Values{"dmarc_rua_mode": {"keep"}}); rec.Code != http.StatusSeeOther {
t.Fatalf("saving the form untouched = %d, want 303:\n%s", rec.Code, rec.Body.String())
}
if got, err := s.h.store.GetDomain(s.d.ID); err != nil || !got.DMARCRuaUserID.Valid || got.DMARCRuaUserID.Int64 != other.ID {
t.Fatalf("an untouched form took the domain over: follows %v, %v; want user %d", got.DMARCRuaUserID, err, other.ID)
}
// An address of its own shows in the field.
if err := s.h.store.SetDomainDMARCAddress(s.d.ID, "reports@hub.example"); err != nil {
t.Fatal(err)
}
body = pageOf(t, s.h.HandleDomainSettings, "/outbound/domains/1/settings")
has(t, "settings", body, `<option value="custom" selected>`, `value="reports@hub.example"`)
}
func TestDomainSettingsShowsTheRateLimit(t *testing.T) {
@@ -216,7 +172,7 @@ func TestDomainSettingsRefusalsStayOnTheSettingsPage(t *testing.T) {
lacks(t, "report address refusal", body, `<b>`)
code, body = post(s.h.HandleDomainDMARC, "reports", url.Values{"dmarc_rua_mode": {"custom"}})
has(t, "report address refusal", body, `Enter a custom report address or choose another mode.`)
has(t, "report address refusal", body, `Enter the report address or choose another option.`)
code, body = post(s.h.HandleDomainDMARC, "reports", url.Values{"dmarc_rua_mode": {"hosted"}})
if code != http.StatusBadRequest {
@@ -6,10 +6,7 @@ import (
"strings"
"testing"
"github.com/mixeme/selfpost/internal/dmarc"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
)
// The user form sets the account e-mail: create stores it, an edit changes it,
@@ -100,92 +97,3 @@ func TestUserFormRefusesABadEmailAndKeepsWhatWasTyped(t *testing.T) {
t.Errorf("a bad address on create = %d, user created: %v", rec.Code, fresh.ID != 0)
}
}
type countingMaps struct{ n int }
func (c *countingMaps) RebuildDMARCMaps(postfix.DMARCMapsConfig) error { c.n++; return nil }
// Changing the e-mail of a user whose default report address is "my account
// e-mail" rebuilds the ingest allow-list, exactly as the Account page does; for
// anyone else, or when the address is not changed, nothing is rebuilt.
func TestUserFormEmailChangeResyncsDMARCOnlyForTheAccountDefault(t *testing.T) {
h, st := inboundHandlers(t)
maps := &countingMaps{}
h.dmarc = dmarc.NewService(st, maps, "mail.example.org", true)
root := userWith(t, st, "root", store.RoleGlobal, store.Reach{})
follower := userWith(t, st, "follower", store.RoleGlobal, store.Reach{})
other := userWith(t, st, "other", store.RoleGlobal, store.Reach{})
if err := st.SetDMARCDefault(follower.ID, store.DMARCDefaultAccount, ""); err != nil {
t.Fatal(err)
}
save := func(who auth.Principal, email string) {
t.Helper()
rec := send(h.HandleUserEdit, &root, "POST", "/server/users/"+idStr(who.ID), map[string]string{"uid": idStr(who.ID)},
url.Values{"username": {who.Username}, "email": {email}, "role": {"global"}})
if rec.Code != http.StatusSeeOther {
t.Fatalf("save %s = %d:\n%s", who.Username, rec.Code, rec.Body.String())
}
}
save(other, "other@example.org")
if maps.n != 0 {
t.Errorf("a user whose default is not the account e-mail rebuilt the maps %d time(s)", maps.n)
}
save(follower, "follower@example.org")
if maps.n != 1 {
t.Errorf("changing the account e-mail of a follower rebuilt the maps %d time(s), want 1", maps.n)
}
save(follower, "follower@example.org")
if maps.n != 1 {
t.Errorf("saving the same address again rebuilt the maps (%d)", maps.n)
}
}
// Deleting a user leaves every domain that follows their default report address
// with none; the confirmation says which, and says nothing of the kind when no
// domain follows.
func TestUserDeleteConfirmationNamesTheDomainsThatLoseTheirReportAddress(t *testing.T) {
h, st := inboundHandlers(t)
root := userWith(t, st, "root", store.RoleGlobal, store.Reach{})
u := userWith(t, st, "shop", store.RoleGlobal, store.Reach{})
path, pv := "/server/users/"+idStr(u.ID)+"/delete", map[string]string{"uid": idStr(u.ID)}
page := func() string { return send(h.HandleUserDeleteConfirm, &root, "GET", path, pv, nil).Body.String() }
if body := page(); strings.Contains(body, "DMARC report address") {
t.Errorf("no domain follows the user, yet the page speaks of DMARC:\n%s", body)
}
var follows []store.Domain
for _, name := range []string{"a.example.com", "b.example.com", "c.example.com", "d.example.com"} {
d, _ := st.AddDomain(name, "mail")
if err := st.SetDomainDMARCUser(d.ID, u.ID); err != nil {
t.Fatal(err)
}
follows = append(follows, d)
}
elsewhere, _ := st.AddDomain("z.example.com", "mail")
if err := st.SetDomainDMARCUser(elsewhere.ID, root.ID); err != nil {
t.Fatal(err)
}
body := page()
want := `<strong>a.example.com</strong>, <strong>b.example.com</strong>, <strong>c.example.com</strong> and 1 more will have no DMARC report address until someone sets one.`
if !strings.Contains(body, want) {
t.Errorf("the delete page is missing %q:\n%s", want, body)
}
if strings.Contains(body, "z.example.com") || strings.Contains(body, "d.example.com") {
t.Error("the page names a domain that does not follow this user, or spells out a fourth name")
}
// What it says is what happens: the follower domains end up with no address.
if rec := send(h.HandleUserDelete, &root, "POST", path, pv, url.Values{}); rec.Code != http.StatusSeeOther {
t.Fatalf("delete = %d", rec.Code)
}
for _, d := range follows {
got, _ := st.GetDomain(d.ID)
rua, err := st.DomainDMARCRua(got, "")
if err != nil || rua != "" {
t.Errorf("%s still reports to %q (%v) after its user was deleted", d.Name, rua, err)
}
}
}
@@ -188,7 +188,7 @@ func TestUserFormSavedUntouchedChangesNothing(t *testing.T) {
}
after, _ := st.GetUser(p.ID)
if after.Username != before.Username || after.PasswordHash != before.PasswordHash || after.Role != before.Role ||
after.Email != before.Email || after.DMARCDefaultMode != before.DMARCDefaultMode ||
after.Email != before.Email ||
!equalReach(after.Reach(), before.Reach()) {
t.Errorf("saving untouched changed the user:\nbefore %+v\nafter %+v", before, after)
}
+1 -6
View File
@@ -93,12 +93,7 @@ func (h *Handlers) overviewDomains(domains []store.Domain, retention int) []view
logf("panel: overview: domain %d: dkim record: %v", d.ID, err)
return
}
reportEmail, err := h.domainReportAddress(d)
if err != nil {
logf("panel: overview: domain %d: dmarc report address: %v", d.ID, err)
return
}
dns, _ := h.domainDNS(d, record, reportEmail, false)
dns, _ := h.domainDNS(d, record, d.DMARCRua, false)
rows[i].DNS = dnsTags(dns.DKIM.Status, dns.SPF.Status, dns.DMARC.Status)
}()
}
-13
View File
@@ -310,7 +310,6 @@ func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u st
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not save user. Please check the logs.", FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
return
}
h.resyncAfterEmailChange(u, email)
if password != "" {
h.endSessionsAfterPasswordSet(r, p, u)
}
@@ -384,18 +383,6 @@ func (h *Handlers) HandleUserDeleteConfirm(w http.ResponseWriter, r *http.Reques
return
}
}
// Domains that follow this user's default report address are left with none.
domains, err := h.store.ListDomains()
if err != nil {
logf("panel: delete user %d: list domains: %v", u.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
for _, d := range domains {
if d.DMARCRuaUserID.Valid && d.DMARCRuaUserID.Int64 == u.ID {
in.Following = append(in.Following, d.Name)
}
}
h.view.Render(w, http.StatusOK, "user-delete", view.NewUserDelete(h.shellMeta(r), in))
}