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:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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+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"><i>@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'brien&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'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`)
|
||||
}
|
||||
|
||||
@@ -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'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'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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user