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
+11
View File
@@ -117,6 +117,17 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
seventeen topics for Overview, Outbound, Inbound, Server and Account - and seventeen topics for Overview, Outbound, Inbound, Server and Account - and
the boxes of nearly every page carry a `?` that opens their topic (owner, the boxes of nearly every page carry a `?` that opens their topic (owner,
2026-10-10). A reader sees the topics of the sections their menu shows. 2026-10-10). A reader sees the topics of the sections their menu shows.
- panel-redesign (design, stage 3): **a domain's DMARC report address is one
of three choices** - no reports (a new domain), the SelfPost hosted
address, or an address typed for the domain - set in Domain settings, with
a button that fills in one's own profile e-mail. The per-user default
report address, a domain "following" a user and the DMARC box on Account
are gone, and with them `users.dmarc_default_mode`,
`users.dmarc_default_address` and `domains.dmarc_rua_user_id` (owner,
2026-10-10; replaces the design of 2026-09-21 described above).
- panel-redesign (design, stage 3): **New password** and **Delete** are in
each row of a domain's Applications table again, as POST forms that look
like the links beside them.
- panel-redesign (stage 2): the **Outbound queue** page is in the new shell - panel-redesign (stage 2): the **Outbound queue** page is in the new shell
with its content unchanged - the listing of `postqueue -p` and how with its content unchanged - the listing of `postqueue -p` and how
retries work. With it every page is built from the component kit: the old retries work. With it every page is built from the component kit: the old
Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 600 KiB

After

Width:  |  Height:  |  Size: 598 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 57 KiB

+5 -25
View File
@@ -64,26 +64,10 @@ func (s *Service) AllowedRecipients() ([]string, error) {
seen[addr] = true seen[addr] = true
out = append(out, addr) out = append(out, addr)
} }
// A user's own default that points at this host is accepted even before a // The instance's generic mailbox is always open: it is the address the
// domain follows it, so the address can be published first. With no such // guide tells a fresh install to publish. A domain's own report address
// default anywhere, the instance's generic mailbox stays open — the address // opens the mailbox it names, if that is on this host.
// the guide tells a fresh install to publish. add(DefaultHostedReportAddress(s.hostname))
users, err := s.store.ListUsers()
if err != nil {
return nil, err
}
anyDefault := false
for _, u := range users {
if def := u.DMARCDefault(); def.Mode == store.DMARCDefaultAccount || def.Mode == store.DMARCDefaultCustom {
if addr := def.Resolve(""); addr != "" {
anyDefault = true
add(addr)
}
}
}
if !anyDefault {
add(DefaultHostedReportAddress(s.hostname))
}
domains, err := s.store.ListDomains() domains, err := s.store.ListDomains()
if err != nil { if err != nil {
return nil, err return nil, err
@@ -91,11 +75,7 @@ func (s *Service) AllowedRecipients() ([]string, error) {
for _, d := range domains { for _, d := range domains {
// add keeps only addresses on this host, so a domain reporting elsewhere // add keeps only addresses on this host, so a domain reporting elsewhere
// (or nowhere) opens nothing here. // (or nowhere) opens nothing here.
rua, err := s.store.DomainDMARCRua(d, HostedReportAddress(s.hostname, d.Name)) add(d.DMARCRua)
if err != nil {
return nil, err
}
add(rua)
} }
sort.Strings(out) sort.Strings(out)
return out, nil return out, nil
+43
View File
@@ -0,0 +1,43 @@
package dmarc
import (
"reflect"
"testing"
"github.com/mixeme/selfpost/internal/store"
)
// The ingest allow-list is the generic mailbox plus the report address of each
// domain that points at this host: a new domain (no reports) and a domain
// reporting to another host open nothing of their own.
func TestAllowedRecipientsFollowTheDomainAddresses(t *testing.T) {
st, err := store.Open(t.TempDir() + "/test.db")
if err != nil {
t.Fatalf("Open: %v", err)
}
defer st.Close()
hosted, _ := st.AddDomain("hosted.example", "sel")
typed, _ := st.AddDomain("typed.example", "sel")
if _, err := st.AddDomain("silent.example", "sel"); err != nil {
t.Fatalf("AddDomain: %v", err)
}
local, _ := st.AddDomain("local.example", "sel")
if err := st.SetDomainDMARCAddress(hosted.ID, HostedReportAddress("mail.example.com", "hosted.example")); err != nil {
t.Fatal(err)
}
if err := st.SetDomainDMARCAddress(typed.ID, "reports@elsewhere.example"); err != nil {
t.Fatal(err)
}
if err := st.SetDomainDMARCAddress(local.ID, "ops@mail.example.com"); err != nil {
t.Fatal(err)
}
got, err := NewService(st, nil, "mail.example.com", true).AllowedRecipients()
if err != nil {
t.Fatalf("AllowedRecipients: %v", err)
}
want := []string{"dmarc-reports+hosted.example@mail.example.com", "dmarc-reports@mail.example.com", "ops@mail.example.com"}
if !reflect.DeepEqual(got, want) {
t.Errorf("allowed recipients = %v, want %v", got, want)
}
}
+5 -10
View File
@@ -23,7 +23,7 @@ type DomainExport struct {
Domain string `json:"domain"` Domain string `json:"domain"`
DKIMSelector string `json:"dkim_selector"` DKIMSelector string `json:"dkim_selector"`
DKIMPrivateKey string `json:"dkim_private_key"` // PKCS#1 PEM DKIMPrivateKey string `json:"dkim_private_key"` // PKCS#1 PEM
DMARCRua *string `json:"dmarc_rua,omitempty"` // nil = follows a user's default; set = the domain's own address ("" = none) DMARCRua string `json:"dmarc_rua,omitempty"` // the domain's report address; absent = no reports
RateLimit *RateLimitExport `json:"rate_limit,omitempty"` RateLimit *RateLimitExport `json:"rate_limit,omitempty"`
Applications []AppExport `json:"applications"` Applications []AppExport `json:"applications"`
} }
@@ -71,14 +71,9 @@ func (s *Service) Export(id int64) (DomainExport, error) {
Domain: d.Name, Domain: d.Name,
DKIMSelector: d.DKIMSelector, DKIMSelector: d.DKIMSelector,
DKIMPrivateKey: string(pem), DKIMPrivateKey: string(pem),
DMARCRua: d.DMARCRua,
Applications: make([]AppExport, 0, len(apps)), Applications: make([]AppExport, 0, len(apps)),
} }
// A domain that follows a user's default exports no address: users do not
// travel with a domain, and whoever imports it becomes the one it follows.
if !d.DMARCRuaUserID.Valid {
rua := d.DMARCRua
exp.DMARCRua = &rua
}
rl, ok, err := s.store.GetRateLimit(store.RateLimitScopeDomain, id) rl, ok, err := s.store.GetRateLimit(store.RateLimitScopeDomain, id)
if err != nil { if err != nil {
return DomainExport{}, fmt.Errorf("export rate limit for %s: %w", d.Name, err) return DomainExport{}, fmt.Errorf("export rate limit for %s: %w", d.Name, err)
@@ -147,12 +142,12 @@ func (s *Service) Import(exp DomainExport) (store.Domain, error) {
return store.Domain{}, err return store.Domain{}, err
} }
if exp.DMARCRua != nil { if exp.DMARCRua != "" {
if err := s.store.SetDomainDMARCAddress(d.ID, *exp.DMARCRua); err != nil { if err := s.store.SetDomainDMARCAddress(d.ID, exp.DMARCRua); err != nil {
s.importRollback(d.ID) s.importRollback(d.ID)
return store.Domain{}, err return store.Domain{}, err
} }
d.DMARCRua = *exp.DMARCRua d.DMARCRua = exp.DMARCRua
} }
if exp.RateLimit != nil { if exp.RateLimit != nil {
if err := s.importRateLimit(store.RateLimitScopeDomain, d.ID, *exp.RateLimit); err != nil { if err := s.importRateLimit(store.RateLimitScopeDomain, d.ID, *exp.RateLimit); err != nil {
+4 -4
View File
@@ -110,11 +110,11 @@ func TestExportImportRoundTrip(t *testing.T) {
if got.Name != "example.com" || got.DKIMSelector != "selfpost" { if got.Name != "example.com" || got.DKIMSelector != "selfpost" {
t.Errorf("imported domain = %+v", got) t.Errorf("imported domain = %+v", got)
} }
if got.DMARCRuaUserID.Valid || got.DMARCRua != "reports@hub.example" { if got.DMARCRua != "reports@hub.example" {
t.Errorf("imported dmarc rua = %q (follows user: %v)", got.DMARCRua, got.DMARCRuaUserID.Valid) t.Errorf("imported dmarc rua = %q", got.DMARCRua)
} }
if exp.DMARCRua == nil || *exp.DMARCRua != "reports@hub.example" { if exp.DMARCRua != "reports@hub.example" {
t.Errorf("exported dmarc rua = %v", exp.DMARCRua) t.Errorf("exported dmarc rua = %q", exp.DMARCRua)
} }
// The DKIM key was imported byte-for-byte, so the DNS record is unchanged. // The DKIM key was imported byte-for-byte, so the DNS record is unchanged.
dstKey, err := dstOdk.ExportKey("example.com", "selfpost") dstKey, err := dstOdk.ExportKey("example.com", "selfpost")
+14 -43
View File
@@ -25,13 +25,13 @@ type Domain struct {
ID int64 ID int64
Name string Name string
DKIMSelector string DKIMSelector string
// Where DMARC aggregate reports go. With DMARCRuaUserID set the domain // Where DMARC aggregate reports go: the address itself, "" meaning no
// follows that user's default (DMARCDefault) and DMARCRua is ignored; // reports (which is what a new domain has). Whether it is the hosted
// otherwise DMARCRua is the address itself, "" meaning no reports. // address or one typed for the domain is told by its value; the store does
DMARCRua string // not know the server's hostname.
DMARCRuaUserID sql.NullInt64 DMARCRua string
CreatedAt time.Time CreatedAt time.Time
AppCount int AppCount int
} }
// AddDomain inserts a new sending domain. The caller is responsible for having // AddDomain inserts a new sending domain. The caller is responsible for having
@@ -60,7 +60,7 @@ func (s *Store) AddDomain(name, selector string) (Domain, error) {
// ordered by name. // ordered by name.
func (s *Store) ListDomains() ([]Domain, error) { func (s *Store) ListDomains() ([]Domain, error) {
rows, err := s.db.Query(` rows, err := s.db.Query(`
SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.dmarc_rua_user_id, d.created_at, SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.created_at,
(SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id) (SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id)
FROM domains d FROM domains d
ORDER BY d.name`) ORDER BY d.name`)
@@ -86,7 +86,7 @@ func (s *Store) ListDomains() ([]Domain, error) {
// The filter is in the query, so a caller cannot forget it. // The filter is in the query, so a caller cannot forget it.
func (s *Store) ListDomainsForUser(userID int64) ([]Domain, error) { func (s *Store) ListDomainsForUser(userID int64) ([]Domain, error) {
rows, err := s.db.Query(` rows, err := s.db.Query(`
SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.dmarc_rua_user_id, d.created_at, SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.created_at,
(SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id) (SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id)
FROM domains d FROM domains d
WHERE EXISTS (SELECT 1 FROM users u WHERE u.id = ? AND u.all_domains = 1) WHERE EXISTS (SELECT 1 FROM users u WHERE u.id = ? AND u.all_domains = 1)
@@ -112,7 +112,7 @@ func (s *Store) ListDomainsForUser(userID int64) ([]Domain, error) {
// ErrDomainNotFound. // ErrDomainNotFound.
func (s *Store) GetDomain(id int64) (Domain, error) { func (s *Store) GetDomain(id int64) (Domain, error) {
row := s.db.QueryRow(` row := s.db.QueryRow(`
SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.dmarc_rua_user_id, d.created_at, SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.created_at,
(SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id) (SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id)
FROM domains d FROM domains d
WHERE d.id = ?`, id) WHERE d.id = ?`, id)
@@ -154,7 +154,7 @@ func scanDomain(r scanRow) (Domain, error) {
d Domain d Domain
createdAt string createdAt string
) )
if err := r.Scan(&d.ID, &d.Name, &d.DKIMSelector, &d.DMARCRua, &d.DMARCRuaUserID, &createdAt, &d.AppCount); err != nil { if err := r.Scan(&d.ID, &d.Name, &d.DKIMSelector, &d.DMARCRua, &createdAt, &d.AppCount); err != nil {
return Domain{}, err return Domain{}, err
} }
d.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) d.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
@@ -172,21 +172,10 @@ func isUniqueViolation(err error) bool {
return false return false
} }
// SetDomainDMARCAddress gives the domain a report address of its own; "" means // SetDomainDMARCAddress sets where the domain's aggregate reports go; "" means
// no aggregate reports. The domain stops following anyone's default. // no reports.
func (s *Store) SetDomainDMARCAddress(id int64, rua string) error { func (s *Store) SetDomainDMARCAddress(id int64, rua string) error {
return s.updateDomainDMARC(id, rua, sql.NullInt64{}) res, err := s.db.Exec("UPDATE domains SET dmarc_rua = ? WHERE id = ?", rua, id)
}
// SetDomainDMARCUser makes the domain follow userID's default report address
// (DMARCDefault). If that user is later deleted the domain falls back to no
// reports rather than to someone else's default.
func (s *Store) SetDomainDMARCUser(id, userID int64) error {
return s.updateDomainDMARC(id, "", sql.NullInt64{Int64: userID, Valid: true})
}
func (s *Store) updateDomainDMARC(id int64, rua string, userID sql.NullInt64) error {
res, err := s.db.Exec("UPDATE domains SET dmarc_rua = ?, dmarc_rua_user_id = ? WHERE id = ?", rua, userID, id)
if err != nil { if err != nil {
return fmt.Errorf("update domain dmarc rua: %w", err) return fmt.Errorf("update domain dmarc rua: %w", err)
} }
@@ -200,24 +189,6 @@ func (s *Store) updateDomainDMARC(id int64, rua string, userID sql.NullInt64) er
return nil return nil
} }
// DomainDMARCRua resolves where the domain's aggregate reports go: its own
// address, or the default of the user it follows. hosted is the SelfPost-hosted
// mailbox for this domain, "" when report ingest is off (the store does not
// know the server's hostname). The result is "" for a policy-only record.
func (s *Store) DomainDMARCRua(d Domain, hosted string) (string, error) {
if !d.DMARCRuaUserID.Valid {
return d.DMARCRua, nil
}
def, err := s.GetDMARCDefault(d.DMARCRuaUserID.Int64)
if errors.Is(err, ErrUserNotFound) {
return "", nil
}
if err != nil {
return "", err
}
return def.Resolve(hosted), nil
}
// DomainExists reports whether a sending domain with this name is configured. // DomainExists reports whether a sending domain with this name is configured.
// Used by the DMARC ingest path to refuse reports for domains this relay does // Used by the DMARC ingest path to refuse reports for domains this relay does
// not send for (security.md: report XML is attacker-supplied input). // not send for (security.md: report XML is attacker-supplied input).
+26 -101
View File
@@ -5,11 +5,9 @@ import (
"testing" "testing"
) )
const hostedForTest = "dmarc+example.com@mail.example.org" // A domain has one report address and nothing else: a fresh domain has none,
// the address is whatever was set last, and "" is no reports.
// A domain either has a report address of its own or follows a user's default; func TestDomainDMARCAddress(t *testing.T) {
// the two never mix, and a fresh domain has neither.
func TestDomainDMARCAddressAndUser(t *testing.T) {
st := openTestStore(t) st := openTestStore(t)
d, err := st.AddDomain("example.com", "sel") d, err := st.AddDomain("example.com", "sel")
if err != nil { if err != nil {
@@ -19,123 +17,50 @@ func TestDomainDMARCAddressAndUser(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("GetDomain: %v", err) t.Fatalf("GetDomain: %v", err)
} }
if got.DMARCRua != "" || got.DMARCRuaUserID.Valid { if got.DMARCRua != "" {
t.Fatalf("a new domain = %q / follows %v, want no reports", got.DMARCRua, got.DMARCRuaUserID) t.Fatalf("a new domain reports to %q, want no reports", got.DMARCRua)
} }
if err := st.SetDomainDMARCAddress(d.ID, "reports@hub.com"); err != nil { for _, rua := range []string{"reports@hub.com", "dmarc+example.com@mail.example.org", ""} {
t.Fatalf("SetDomainDMARCAddress: %v", err) if err := st.SetDomainDMARCAddress(d.ID, rua); err != nil {
} t.Fatalf("SetDomainDMARCAddress(%q): %v", rua, err)
got, _ = st.GetDomain(d.ID) }
if rua, err := st.DomainDMARCRua(got, hostedForTest); err != nil || rua != "reports@hub.com" { if got, _ = st.GetDomain(d.ID); got.DMARCRua != rua {
t.Fatalf("own address resolves to %q, %v", rua, err) t.Fatalf("set %q, read %q", rua, got.DMARCRua)
} }
if err := st.CreateGlobalUser("admin", "hash"); err != nil {
t.Fatalf("CreateGlobalUser: %v", err)
}
u, _ := st.GetUserByUsername("admin")
if err := st.SetDomainDMARCUser(d.ID, u.ID); err != nil {
t.Fatalf("SetDomainDMARCUser: %v", err)
}
got, _ = st.GetDomain(d.ID)
if got.DMARCRua != "" || !got.DMARCRuaUserID.Valid || got.DMARCRuaUserID.Int64 != u.ID {
t.Fatalf("following a user left %q / %v; the own address must be dropped", got.DMARCRua, got.DMARCRuaUserID)
}
if err := st.SetDomainDMARCAddress(d.ID, ""); err != nil {
t.Fatalf("SetDomainDMARCAddress none: %v", err)
}
got, _ = st.GetDomain(d.ID)
if got.DMARCRua != "" || got.DMARCRuaUserID.Valid {
t.Fatalf("none = %q / %v", got.DMARCRua, got.DMARCRuaUserID)
} }
if err := st.SetDomainDMARCAddress(d.ID+100, "x@y.com"); !errors.Is(err, ErrDomainNotFound) { if err := st.SetDomainDMARCAddress(d.ID+100, "x@y.com"); !errors.Is(err, ErrDomainNotFound) {
t.Fatalf("unknown domain = %v, want ErrDomainNotFound", err) t.Errorf("unknown domain = %v, want ErrDomainNotFound", err)
} }
} }
// A domain that follows a user resolves to whatever that user's default says // Deleting a user changes no domain's report address: the address belongs to
// today — every mode — and changes with it. // the domain, not to whoever set it.
func TestDomainFollowsUserDefault(t *testing.T) { func TestDeletingAUserLeavesEveryDomainAddressAlone(t *testing.T) {
st := openTestStore(t) st := openTestStore(t)
if err := st.CreateGlobalUser("admin", "hash"); err != nil { if err := st.CreateGlobalUser("admin", "hash"); err != nil {
t.Fatalf("CreateGlobalUser: %v", err) t.Fatalf("CreateGlobalUser: %v", err)
} }
u, _ := st.GetUserByUsername("admin")
if err := st.UpdateUser(u.ID, "admin", "hash", "mix@example.org"); err != nil {
t.Fatalf("UpdateUser: %v", err)
}
d, _ := st.AddDomain("example.com", "sel")
if err := st.SetDomainDMARCUser(d.ID, u.ID); err != nil {
t.Fatalf("SetDomainDMARCUser: %v", err)
}
d, _ = st.GetDomain(d.ID)
for _, tc := range []struct {
mode, address string
hosted string
want string
}{
{DMARCDefaultNone, "", hostedForTest, ""},
{DMARCDefaultAccount, "", hostedForTest, "mix@example.org"},
{DMARCDefaultCustom, "dmarc@hub.example", hostedForTest, "dmarc@hub.example"},
{DMARCDefaultHosted, "", hostedForTest, hostedForTest},
// Ingest switched off: a hosted default has no mailbox to point at.
{DMARCDefaultHosted, "", "", ""},
// The custom address belongs to the custom mode only.
{DMARCDefaultAccount, "ignored@hub.example", hostedForTest, "mix@example.org"},
} {
if err := st.SetDMARCDefault(u.ID, tc.mode, tc.address); err != nil {
t.Fatalf("SetDMARCDefault(%s): %v", tc.mode, err)
}
got, err := st.DomainDMARCRua(d, tc.hosted)
if err != nil || got != tc.want {
t.Errorf("mode %s, hosted %q: resolved %q, %v; want %q", tc.mode, tc.hosted, got, err, tc.want)
}
}
def, err := st.GetDMARCDefault(u.ID)
if err != nil || def.Address != "" || def.Username != "admin" {
t.Errorf("default after a non-custom mode = %+v, %v; the custom address must be cleared", def, err)
}
if err := st.SetDMARCDefault(u.ID, "profile", ""); err == nil {
t.Error("an unknown mode was stored")
}
if err := st.SetDMARCDefault(u.ID+100, DMARCDefaultNone, ""); !errors.Is(err, ErrUserNotFound) {
t.Errorf("unknown user = %v, want ErrUserNotFound", err)
}
}
// Deleting the user a domain follows must leave the domain with no report
// address — not with another user's default, and not with a dangling id.
func TestDeletingTheFollowedUserLeavesNoReports(t *testing.T) {
st := openTestStore(t)
if err := st.CreateGlobalUser("admin", "hash"); err != nil {
t.Fatalf("CreateGlobalUser: %v", err)
}
d, _ := st.AddDomain("example.com", "sel")
id, err := st.CreateUser("second", "hash", RoleGlobal, Reach{}) id, err := st.CreateUser("second", "hash", RoleGlobal, Reach{})
if err != nil { if err != nil {
t.Fatalf("CreateUser: %v", err) t.Fatalf("CreateUser: %v", err)
} }
if err := st.SetDMARCDefault(id, DMARCDefaultCustom, "dmarc@hub.example"); err != nil { if err := st.UpdateUser(id, "second", "hash", "second@hub.example"); err != nil {
t.Fatalf("SetDMARCDefault: %v", err) t.Fatalf("UpdateUser: %v", err)
} }
if err := st.SetDomainDMARCUser(d.ID, id); err != nil { typed, _ := st.AddDomain("typed.example", "sel")
t.Fatalf("SetDomainDMARCUser: %v", err) none, _ := st.AddDomain("none.example", "sel")
if err := st.SetDomainDMARCAddress(typed.ID, "second@hub.example"); err != nil {
t.Fatalf("SetDomainDMARCAddress: %v", err)
} }
if err := st.DeleteUser(id); err != nil { if err := st.DeleteUser(id); err != nil {
t.Fatalf("DeleteUser: %v", err) t.Fatalf("DeleteUser: %v", err)
} }
got, err := st.GetDomain(d.ID) if got, _ := st.GetDomain(typed.ID); got.DMARCRua != "second@hub.example" {
if err != nil { t.Errorf("typed address after deleting the user = %q", got.DMARCRua)
t.Fatalf("GetDomain: %v", err)
} }
if got.DMARCRuaUserID.Valid || got.DMARCRua != "" { if got, _ := st.GetDomain(none.ID); got.DMARCRua != "" {
t.Fatalf("after deleting the followed user: %q / %v, want no reports", got.DMARCRua, got.DMARCRuaUserID) t.Errorf("no-reports domain after deleting the user = %q", got.DMARCRua)
}
if rua, err := st.DomainDMARCRua(got, hostedForTest); err != nil || rua != "" {
t.Fatalf("resolved %q, %v; want no reports", rua, err)
} }
} }
+20 -27
View File
@@ -24,27 +24,21 @@ CREATE TABLE settings (
-- exists. role is a reach, not a rank: 'global' sees the whole instance, -- exists. role is a reach, not a rank: 'global' sees the whole instance,
-- 'domain' only what is assigned below. -- 'domain' only what is assigned below.
-- --
-- email is the user's own address — what the panel writes to. The DMARC -- email is the user's own address — what the panel writes to, and what the
-- default is a separate choice that may use it: dmarc_default_mode says where -- button beside a domain's typed report address fills it with.
-- aggregate reports go for a domain that follows this user
-- (domains.dmarc_rua_user_id): 'hosted' (SelfPost's own mailbox for that
-- domain), 'account' (email above), 'custom' (dmarc_default_address) or 'none'.
-- --
-- all_domains / all_inbound_domains widen a 'domain' user's list to every -- all_domains / all_inbound_domains widen a 'domain' user's list to every
-- domain of that direction, including the ones added later; with a flag set -- domain of that direction, including the ones added later; with a flag set
-- the rows of the matching assignment table are ignored. -- the rows of the matching assignment table are ignored.
CREATE TABLE users ( CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('global', 'domain')), role TEXT NOT NULL CHECK (role IN ('global', 'domain')),
email TEXT NOT NULL DEFAULT '', email TEXT NOT NULL DEFAULT '',
dmarc_default_mode TEXT NOT NULL DEFAULT 'none' all_domains INTEGER NOT NULL DEFAULT 0 CHECK (all_domains IN (0, 1)),
CHECK (dmarc_default_mode IN ('hosted', 'account', 'custom', 'none')), all_inbound_domains INTEGER NOT NULL DEFAULT 0 CHECK (all_inbound_domains IN (0, 1)),
dmarc_default_address TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL
all_domains INTEGER NOT NULL DEFAULT 0 CHECK (all_domains IN (0, 1)),
all_inbound_domains INTEGER NOT NULL DEFAULT 0 CHECK (all_inbound_domains IN (0, 1)),
created_at TEXT NOT NULL
); );
-- Panel login sessions. Persisted so a login survives a container restart or -- Panel login sessions. Persisted so a login survives a container restart or
@@ -65,18 +59,17 @@ CREATE INDEX idx_sessions_expires_at ON sessions (expires_at);
-- Sending (outbound) domains. DKIM keys themselves live on disk under /data; -- Sending (outbound) domains. DKIM keys themselves live on disk under /data;
-- this row records the selector and metadata. -- this row records the selector and metadata.
-- --
-- Where the domain's DMARC aggregate reports go (rua=): with dmarc_rua_user_id -- Where the domain's DMARC aggregate reports go (rua=): dmarc_rua is the
-- set, the domain follows that user's default and dmarc_rua is ignored; -- address itself, '' meaning no reports — which is what a new domain has. The
-- otherwise dmarc_rua is the address itself, '' meaning no reports. Deleting -- three choices (no reports, SelfPost hosted, an address typed for the domain)
-- the user leaves the domain at "none" rather than silently handing its -- are told apart by the value: the hosted one is the address SelfPost derives
-- reports to someone else. -- for the domain and the server's hostname. No user is involved.
CREATE TABLE domains ( CREATE TABLE domains (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,
dkim_selector TEXT NOT NULL, dkim_selector TEXT NOT NULL,
dmarc_rua TEXT NOT NULL DEFAULT '', dmarc_rua TEXT NOT NULL DEFAULT '',
dmarc_rua_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, created_at TEXT NOT NULL
created_at TEXT NOT NULL
); );
-- Outbound domains assigned to a 'domain' user. -- Outbound domains assigned to a 'domain' user.
+11 -14
View File
@@ -40,9 +40,9 @@ func TestBaselineSchema(t *testing.T) {
st := openTestStore(t) st := openTestStore(t)
want := map[string]string{ want := map[string]string{
"settings": "key value", "settings": "key value",
"users": "id username password_hash role email dmarc_default_mode dmarc_default_address all_domains all_inbound_domains created_at", "users": "id username password_hash role email all_domains all_inbound_domains created_at",
"sessions": "token_hash user_id created_at expires_at", "sessions": "token_hash user_id created_at expires_at",
"domains": "id name dkim_selector dmarc_rua dmarc_rua_user_id created_at", "domains": "id name dkim_selector dmarc_rua created_at",
"user_domains": "user_id domain_id", "user_domains": "user_id domain_id",
"applications": "id domain_id login address_mode auth_ip_restrict auth_allowed_ips created_at", "applications": "id domain_id login address_mode auth_ip_restrict auth_allowed_ips created_at",
"application_addresses": "id application_id address", "application_addresses": "id application_id address",
@@ -223,33 +223,30 @@ func TestOpenRefusesANewerSchema(t *testing.T) {
// table does when either side goes away. // table does when either side goes away.
func TestBaselineConstraints(t *testing.T) { func TestBaselineConstraints(t *testing.T) {
st := openTestStore(t) st := openTestStore(t)
insert := func(role, mode string, all int) error { insert := func(name, role string, all int) error {
_, err := st.db.Exec( _, err := st.db.Exec(
"INSERT INTO users (username, password_hash, role, dmarc_default_mode, all_domains, created_at) VALUES (?, 'h', ?, ?, ?, 'now')", "INSERT INTO users (username, password_hash, role, all_domains, created_at) VALUES (?, 'h', ?, ?, 'now')",
role+mode, role, mode, all) name, role, all)
return err return err
} }
if err := insert("global", "none", 0); err != nil { if err := insert("plain", "global", 0); err != nil {
t.Fatalf("a plain global user: %v", err) t.Fatalf("a plain global user: %v", err)
} }
if err := insert("domain", "hosted", 1); err != nil { if err := insert("everything", "domain", 1); err != nil {
t.Fatalf("a domain user with All: %v", err) t.Fatalf("a domain user with All: %v", err)
} }
if err := insert("domain_admin", "none", 0); err == nil { if err := insert("old-role", "domain_admin", 0); err == nil {
t.Error("the 1.x role name domain_admin was accepted") t.Error("the 1.x role name domain_admin was accepted")
} }
if err := insert("global", "profile", 0); err == nil { if err := insert("two", "domain", 2); err == nil {
t.Error("an unknown dmarc_default_mode was accepted")
}
if err := insert("domain", "custom", 2); err == nil {
t.Error("all_domains = 2 was accepted") t.Error("all_domains = 2 was accepted")
} }
u, err := st.GetUserByUsername("domainhosted") u, err := st.GetUserByUsername("everything")
if err != nil { if err != nil {
t.Fatalf("GetUserByUsername: %v", err) t.Fatalf("GetUserByUsername: %v", err)
} }
if !u.AllDomains || u.AllInboundDomains || u.DMARCDefaultMode != DMARCDefaultHosted { if !u.AllDomains || u.AllInboundDomains {
t.Errorf("flags read back as %+v", u) t.Errorf("flags read back as %+v", u)
} }
+5 -95
View File
@@ -29,59 +29,15 @@ const (
RoleDomain Role = "domain" RoleDomain Role = "domain"
) )
// How a user's default DMARC report address is chosen (users.dmarc_default_mode).
const (
DMARCDefaultHosted = "hosted" // SelfPost's own mailbox for the domain
DMARCDefaultAccount = "account" // the user's account e-mail
DMARCDefaultCustom = "custom" // DMARCDefault.Address
DMARCDefaultNone = "none" // no aggregate reports
)
// DMARCDefault is a user's default report address: the choice a domain takes
// over when it follows that user (Domain.DMARCRuaUserID).
type DMARCDefault struct {
UserID int64
Username string
Mode string
Address string // for DMARCDefaultCustom
Email string // the account e-mail, for DMARCDefaultAccount
}
// Resolve returns the address the default stands for on one domain. hosted is
// the SelfPost-hosted mailbox for that domain, "" when report ingest is off —
// a hosted default then resolves to no reports.
func (d DMARCDefault) Resolve(hosted string) string {
switch d.Mode {
case DMARCDefaultHosted:
return hosted
case DMARCDefaultAccount:
return d.Email
case DMARCDefaultCustom:
return d.Address
}
return ""
}
// ValidDMARCDefaultMode reports whether mode is one of the stored choices.
func ValidDMARCDefaultMode(mode string) bool {
switch mode {
case DMARCDefaultHosted, DMARCDefaultAccount, DMARCDefaultCustom, DMARCDefaultNone:
return true
}
return false
}
// User is a panel login (not an application SASL account). // User is a panel login (not an application SASL account).
type User struct { type User struct {
ID int64 ID int64
Username string Username string
PasswordHash string PasswordHash string
Role Role Role Role
// Email is the user's own address — what the panel writes to. DMARC may // Email is the user's own address — what the panel writes to, and what the
// use it (DMARCDefaultAccount) but it is not a DMARC field. // button beside a domain's typed report address fills it with.
Email string Email string
DMARCDefaultMode string
DMARCDefaultAddress string
// AllDomains / AllInboundDomains widen a domain user's list to every // AllDomains / AllInboundDomains widen a domain user's list to every
// domain of that direction, including those added later; the id lists // domain of that direction, including those added later; the id lists
// are then ignored. // are then ignored.
@@ -92,19 +48,14 @@ type User struct {
InboundDomainIDs []int64 InboundDomainIDs []int64
} }
// DMARCDefault returns the user's default report address choice. const userColumns = "id, username, password_hash, role, email, all_domains, all_inbound_domains, created_at"
func (u User) DMARCDefault() DMARCDefault {
return DMARCDefault{UserID: u.ID, Username: u.Username, Mode: u.DMARCDefaultMode, Address: u.DMARCDefaultAddress, Email: u.Email}
}
const userColumns = "id, username, password_hash, role, email, dmarc_default_mode, dmarc_default_address, all_domains, all_inbound_domains, created_at"
func scanUser(r scanRow) (User, error) { func scanUser(r scanRow) (User, error) {
var ( var (
u User u User
createdAt string createdAt string
) )
err := r.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.Email, &u.DMARCDefaultMode, &u.DMARCDefaultAddress, err := r.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.Email,
&u.AllDomains, &u.AllInboundDomains, &createdAt) &u.AllDomains, &u.AllInboundDomains, &createdAt)
if err != nil { if err != nil {
return User{}, err return User{}, err
@@ -318,47 +269,6 @@ func (s *Store) UpdateUser(id int64, username, passwordHash, email string) error
return nil return nil
} }
// SetDMARCDefault stores the user's default report address choice. address is
// kept only for DMARCDefaultCustom. Every domain that follows this user
// resolves to the new choice from the next read on.
func (s *Store) SetDMARCDefault(userID int64, mode, address string) error {
if !ValidDMARCDefaultMode(mode) {
return fmt.Errorf("set dmarc default: unknown mode %q", mode)
}
if mode != DMARCDefaultCustom {
address = ""
}
res, err := s.db.Exec(
"UPDATE users SET dmarc_default_mode = ?, dmarc_default_address = ? WHERE id = ?", mode, address, userID)
if err != nil {
return fmt.Errorf("set dmarc default: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("set dmarc default: %w", err)
}
if n == 0 {
return ErrUserNotFound
}
return nil
}
// GetDMARCDefault returns a user's default report address choice, or
// ErrUserNotFound.
func (s *Store) GetDMARCDefault(userID int64) (DMARCDefault, error) {
d := DMARCDefault{UserID: userID}
err := s.db.QueryRow(
"SELECT username, dmarc_default_mode, dmarc_default_address, email FROM users WHERE id = ?", userID,
).Scan(&d.Username, &d.Mode, &d.Address, &d.Email)
if errors.Is(err, sql.ErrNoRows) {
return DMARCDefault{}, ErrUserNotFound
}
if err != nil {
return DMARCDefault{}, fmt.Errorf("get dmarc default: %w", err)
}
return d, nil
}
// SetUserRole updates a user's role. // SetUserRole updates a user's role.
func (s *Store) SetUserRole(userID int64, role Role) error { func (s *Store) SetUserRole(userID int64, role Role) error {
res, err := s.db.Exec("UPDATE users SET role = ? WHERE id = ?", role, userID) res, err := s.db.Exec("UPDATE users SET role = ? WHERE id = ?", role, userID)
+2 -5
View File
@@ -26,11 +26,8 @@ func TestUpdateUser(t *testing.T) {
if got.Email != "reports@hub.example" { if got.Email != "reports@hub.example" {
t.Fatalf("email = %q", got.Email) t.Fatalf("email = %q", got.Email)
} }
// The account e-mail is the user's own: saving it does not make it anyone's // The account e-mail is the user's own: nothing is mirrored into the
// report address, and nothing is mirrored into the instance settings. // instance settings.
if got.DMARCDefaultMode != DMARCDefaultNone {
t.Fatalf("dmarc default mode = %q after setting the e-mail, want %q", got.DMARCDefaultMode, DMARCDefaultNone)
}
if v, err := st.GetSetting("dmarc_report_email"); err == nil && v != "" { if v, err := st.GetSetting("dmarc_report_email"); err == nil && v != "" {
t.Fatalf("the e-mail leaked into the instance settings: %q", v) t.Fatalf("the e-mail leaked into the instance settings: %q", v)
} }
-7
View File
@@ -50,13 +50,6 @@ func (h *Handlers) hostedDMARCAddress(domainName string) string {
return dmarc.HostedReportAddress(h.cfg.Hostname, domainName) 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 { func domainNameSet(domains []store.Domain) map[string]bool {
m := make(map[string]bool, len(domains)) m := make(map[string]bool, len(domains))
for _, d := range domains { for _, d := range domains {
+11 -132
View File
@@ -1,13 +1,10 @@
package handlers package handlers
import ( import (
"context"
"errors" "errors"
"net/http" "net/http"
"strings" "strings"
"time"
"github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/validate" "github.com/mixeme/selfpost/internal/web/validate"
"github.com/mixeme/selfpost/internal/web/view" "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 // 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 // page: who they are to the panel (username, e-mail) and their password. Every
// default DMARC report address. Every role has it. Each part is its own form // role has it. Each part is its own form and its own POST, so changing one
// and its own POST, so changing one never asks for the others. // never asks for the others.
// //
// The e-mail is the user's — what the panel writes to. The DMARC default is a // The e-mail is the user's — what the panel writes to. A domain's DMARC report
// separate choice that may use it: a domain whose report address is "this // address is set in the domain's settings, where a button fills it with this
// user's default" resolves to whatever is chosen here (store.DMARCDefault). // e-mail; nothing here refers to a domain.
// accountForm is what the page shows back: the stored values, or the ones just // accountForm is what the page shows back: the stored values, or the ones just
// submitted when a form is re-rendered with an error. // submitted when a form is re-rendered with an error.
type accountForm struct { type accountForm struct {
Err string Err string
Username string Username string
Email string Email string
DMARCMode string
DMARCAddress string
} }
func (h *Handlers) accountUser(w http.ResponseWriter, r *http.Request) (store.User, bool) { 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 { 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. // 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) { 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))
page := view.NewAccount(h.shellMeta(r), string(u.Role), hosted, u.Email)
page.Username, page.Email = f.Username, f.Email page.Username, page.Email = f.Username, f.Email
page.DMARCSelected, page.DMARCAddress = f.DMARCMode, f.DMARCAddress
page.WithResult(accountFlash(r), f.Err) 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) 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 { func accountFlash(r *http.Request) string {
switch r.URL.Query().Get("done") { switch r.URL.Query().Get("done") {
case "profile": case "profile":
return "Profile saved." return "Profile saved."
case "password": case "password":
return "Password changed. Any other signed-in sessions were signed out." return "Password changed. Any other signed-in sessions were signed out."
case "dmarc":
return "Default report address saved."
} }
return "" 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.") fail(http.StatusInternalServerError, "Could not save the profile. Please check the logs and try again.")
return 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) 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) 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) logf("panel: user %d changed their password", u.ID)
http.Redirect(w, r, "/account?done=password", http.StatusSeeOther) 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 // 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. // POST, and none of them touches what another saves.
func TestAccountPageShowsThreeForms(t *testing.T) { func TestAccountPageShowsTwoForms(t *testing.T) {
h, _ := settingsServer(t) h, _ := settingsServer(t)
out := getBody(t, h.HandleAccount, "/account") out := getBody(t, h.HandleAccount, "/account")
for _, want := range []string{ for _, want := range []string{
`action="/account/profile"`, `name="email"`, `action="/account/profile"`, `name="email"`,
`action="/account/password"`, `name="current_password"`, `action="/account/password"`, `name="current_password"`,
`action="/account/dmarc"`, `name="dmarc_default"`,
} { } {
if !strings.Contains(out, want) { if !strings.Contains(out, want) {
t.Errorf("account page missing %q", 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) { 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 // 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) { func TestAccountProfile(t *testing.T) {
h, _ := settingsServer(t) h, _ := settingsServer(t)
before, _ := h.store.GetUser(globalPrincipal.ID) 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, rec := postFormAs(h.HandleAccountProfile, globalPrincipal, "/account/profile", nil,
url.Values{"username": {"operator"}, "email": {"mix@example.org"}}) 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" { if u.Username != "operator" || u.Email != "mix@example.org" {
t.Errorf("profile = %q / %q", u.Username, u.Email) t.Errorf("profile = %q / %q", u.Username, u.Email)
} }
if u.PasswordHash != before.PasswordHash || u.DMARCDefaultMode != store.DMARCDefaultNone { if u.PasswordHash != before.PasswordHash {
t.Errorf("saving the profile changed the password or the DMARC default (%q)", u.DMARCDefaultMode) 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{ 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. // A domain administrator has the same Account page; it is not a Server page.
func TestAccountIsOpenToADomainAdministrator(t *testing.T) { func TestAccountIsOpenToADomainAdministrator(t *testing.T) {
h, _ := settingsServer(t) h, _ := settingsServer(t)
@@ -178,7 +129,7 @@ func TestAccountPageIsDrawnInTheShell(t *testing.T) {
out := getBody(t, h.HandleAccount, "/account") out := getBody(t, h.HandleAccount, "/account")
for _, want := range []string{ for _, want := range []string{
`class="navbar is-primary"`, `Signed in as admin · global`, `<h1 class="title is-3">Account</h1>`, `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) { if !strings.Contains(out, want) {
t.Errorf("account page missing %q", want) t.Errorf("account page missing %q", want)
@@ -222,7 +173,6 @@ func TestAccountResultsAreTheFlash(t *testing.T) {
for done, want := range map[string]string{ for done, want := range map[string]string{
"profile": "Profile saved.", "profile": "Profile saved.",
"password": "Password changed.", "password": "Password changed.",
"dmarc": "Default report address saved.",
} { } {
out := getBody(t, h.HandleAccount, "/account?done="+done) out := getBody(t, h.HandleAccount, "/account?done="+done)
if !strings.Contains(out, `notification is-success is-light`) || !strings.Contains(out, want) { 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 // What DNS actually publishes for the domain today, checked against the key
// this server signs with. // this server signs with.
reportEmail, err := h.domainReportAddress(d) reportEmail := d.DMARCRua
if err != nil {
logf("panel: domain %d: dmarc report address: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
dns, srv := h.domainDNS(d, record, reportEmail, false) dns, srv := h.domainDNS(d, record, reportEmail, false)
records := []view.Record{ records := []view.Record{
view.DKIMRecord(d.DKIMSelector, record.Name, record.Value, dnsCheck(dns.DKIM)), 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{ records = append(records, view.DMARCRecord(view.DMARCRecordInput{
Host: dnscheck.DMARCRecordName(d.Name), Value: dnscheck.DMARCExample(reportEmail), Check: dnsCheck(dns.DMARC), 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) && SameDomain: reportEmail != "" && strings.EqualFold(dnscheck.EmailDomain(reportEmail), d.Name) &&
!(h.cfg.DMARCEnabled && dmarc.IsHostedOnHostname(reportEmail, h.cfg.Hostname)), !(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} 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 // reportSource says in words where a domain's DMARC reports go, for the note
// the user it follows, the hosted address, an address of its own, or nowhere. // of its DMARC record: the hosted address or the one typed for the domain,
func (h *Handlers) reportSource(d store.Domain, reportEmail string) string { // written out, or "no reports".
switch { func (h *Handlers) reportSource(d store.Domain) string {
case d.DMARCRuaUserID.Valid: if d.DMARCRua == "" {
if def, err := h.store.GetDMARCDefault(d.DMARCRuaUserID.Int64); err == nil && def.Username != "" { return "no reports"
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"
} }
return "a custom address" return d.DMARCRua
} }
// outAppRow is one line of the Applications table of a domain. // 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{ return view.OutAppRow{
Login: a.Login, Senders: senders, Activity: view.FormatActivity(stats.Total, stats.PeakPerHour), Limits: limits, 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 }, nil
} }
@@ -214,76 +203,40 @@ func (h *Handlers) renderDomainSettings(w http.ResponseWriter, r *http.Request,
rate.Computed = intOrBlank(rl.MaxMessages) rate.Computed = intOrBlank(rl.MaxMessages)
} }
options, selected, custom, help := h.reportAddressForm(r, d) options, selected, custom, mine := h.reportAddressForm(r, d)
if sv.RuaMode != "" { if sv.RuaMode != "" {
selected, custom = sv.RuaMode, sv.RuaCustom selected, custom = sv.RuaMode, sv.RuaCustom
} }
page := view.NewOutDomainSettings(h.shellMeta(r), d.ID, d.Name, d.AppCount, p.IsGlobal()). 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). WithExport(validate.MinSecretFilePasswordLen).
WithRateLimit(rate). WithRateLimit(rate).
WithResult("", sv.Err) WithResult("", sv.Err)
h.view.Render(w, status, "out-domain-settings", page) h.view.Render(w, status, "out-domain-settings", page)
} }
// reportAddressForm is the choices of the report-address select for a domain // reportAddressForm is the report-address form of a domain: the three choices
// and the signed-in user. "inherit" makes the domain follow the default of the // of the select, the one the stored address stands for, the address typed for
// user who saves the form (HandleDomainDMARC), so what the option says depends // the domain (only in the typed choice) and the button that fills it with the
// on whom the domain follows now: the user themselves, someone else, or nobody. // signed-in user's profile e-mail ("" when the profile has none). SelfPost
// A domain that follows someone else also offers "keep" — that user's default, // hosted is offered only while hosted reports are enabled.
// 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, mine string) {
func (h *Handlers) reportAddressForm(r *http.Request, d store.Domain) (options []view.Option, selected, custom string, help view.Text) { options = append(options, view.Option{Value: ruaNone, Label: "No reports"})
hosted := h.hostedDMARCAddress(d.Name) if hosted := h.hostedDMARCAddress(d.Name); hosted != "" {
me, _ := h.principal(r) options = append(options, view.Option{Value: ruaHosted, Label: "SelfPost hosted — " + hosted})
mine, _ := h.store.GetDMARCDefault(me.ID)
address := func(a string) string {
if a == "" {
return "no report address yet"
}
return a
} }
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"), selected = h.reportChoice(d)
"; 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.") if selected == ruaCustom {
inherit := view.Option{Value: "inherit", Label: "My default — " + myAddr} custom = d.DMARCRua
followsOther := d.DMARCRuaUserID.Valid && d.DMARCRuaUserID.Int64 != me.ID }
switch { if p, ok := h.principal(r); ok {
case followsOther: if u, err := h.store.GetUser(p.ID); err == nil {
inherit.Label = "My default instead — " + myAddr + " (you are " + me.Username + ")" mine = u.Email
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))
} }
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 != "" { return options, selected, custom, mine
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
} }
// formatBound writes a multiplier bound the short way: "1.5", "5". // 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) http.Error(w, "internal error", http.StatusInternalServerError)
return return
} }
reportEmail, err := h.domainReportAddress(d) h.domainDNS(d, record, d.DMARCRua, true)
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)
http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?rechecked=1", d.ID), http.StatusSeeOther) 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") 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) h.renderBackupPage(w, r, status, msg)
return return
} }
// An export without an address of its own followed someone's default on the if exp.DMARCRua != "" {
// server it came from; here it follows whoever imports it. h.resyncDMARC("domain import")
if exp.DMARCRua == nil {
if p, ok := h.principal(r); ok {
h.followCreator(d.ID, p.ID)
}
} }
http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?imported=1", d.ID), http.StatusSeeOther) 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" "strings"
"github.com/mixeme/selfpost/internal/dmarc" "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/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) { func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
w.Header().Set("Allow", "POST") w.Header().Set("Allow", "POST")
@@ -25,9 +31,6 @@ func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) {
return 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 := "" rua := ""
mode := strings.TrimSpace(r.PostFormValue("dmarc_rua_mode")) mode := strings.TrimSpace(r.PostFormValue("dmarc_rua_mode"))
// A refusal shows the form again with the choice that was made. // 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"))}) h.renderDomainSettings(w, r, status, d, settingsView{Err: msg, RuaMode: mode, RuaCustom: strings.TrimSpace(r.PostFormValue("dmarc_rua_email"))})
} }
switch mode { switch mode {
case "keep": case ruaNone:
// The domain follows another user's default and the form left it so. case ruaCustom:
// 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":
email := strings.TrimSpace(r.PostFormValue("dmarc_rua_email")) email := strings.TrimSpace(r.PostFormValue("dmarc_rua_email"))
if err := validate.Email(email); err != nil { if err := validate.Email(email); err != nil {
refuse(http.StatusBadRequest, err.Error()) refuse(http.StatusBadRequest, err.Error())
return return
} }
if email == "" { 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 return
} }
rua = email rua = email
case "hosted": case ruaHosted:
if h.dmarc == nil || !h.dmarc.Enabled() { if h.dmarc == nil || !h.dmarc.Enabled() {
refuse(http.StatusBadRequest, "SelfPost-hosted reports are not enabled on this server.") refuse(http.StatusBadRequest, "SelfPost-hosted reports are not enabled on this server.")
return return
@@ -69,22 +61,46 @@ func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) {
return return
} }
var err error if err := h.store.SetDomainDMARCAddress(d.ID, rua); err != nil {
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 {
logf("panel: domain %d: save dmarc rua: %v", d.ID, err) 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.") refuse(http.StatusInternalServerError, "Could not save DMARC settings. Please check the logs and try again.")
return return
} }
if h.dmarc != nil && h.dmarc.Enabled() { h.resyncDMARC("domain report address")
if err := h.dmarc.Resync(); err != nil {
logf("panel: domain %d: dmarc resync: %v", d.ID, err)
}
}
h.dns.Forget(d.Name) h.dns.Forget(d.Name)
http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?dmarc=1", d.ID), http.StatusSeeOther) 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"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"regexp"
"strings" "strings"
"testing" "testing"
"github.com/mixeme/selfpost/internal/dmarc"
"github.com/mixeme/selfpost/internal/domain" "github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth" "github.com/mixeme/selfpost/internal/web/auth"
) )
@@ -24,78 +27,269 @@ func postFormAs(h http.HandlerFunc, p auth.Principal, target string, pathValues
return rec return rec
} }
// The domain form's "Same as Settings" is, on the 2.0 schema, a domain that // dmarcRecordingMaps stands in for Postfix: the allow-lists a resync would write.
// follows the default of the user who chose it; every other choice gives the type dmarcRecordingMaps struct{ got []postfix.DMARCMapsConfig }
// 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)
}
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. // hostedStand is an app stand whose server receives hosted DMARC reports.
func TestFollowCreator(t *testing.T) { func hostedStand(t *testing.T) (appStand, *dmarcRecordingMaps) {
h, _ := settingsServer(t) t.Helper()
d, err := h.store.AddDomain("example.com", "selfpost") 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 { if err != nil {
t.Fatalf("AddDomain: %v", err) t.Fatal(err)
} }
h.followCreator(d.ID, globalPrincipal.ID) return d.DMARCRua
got, err := h.store.GetDomain(d.ID) }
if err != nil {
t.Fatalf("GetDomain: %v", err) 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 { return false
t.Fatalf("the domain follows %v, want its creator (user %d)", got.DMARCRuaUserID, globalPrincipal.ID) }
// 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 s.h.domains = domain.NewService(s.h.store, odk, s.h.apps, "mail")
// domain publishes a policy-only record — as a fresh 1.x install did. rec := postFormAs(s.h.HandleAddDomain, globalPrincipal, "/outbound/domains", nil, url.Values{"name": {"new.example"}})
if rua, err := h.domainReportAddress(got); err != nil || rua != "" { if rec.Code != http.StatusSeeOther {
t.Fatalf("resolved %q, %v; want no report address yet", rua, err) 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) logf("panel: domains: domain %d: dkim record: %v", d.ID, err)
return return
} }
reportEmail, err := h.domainReportAddress(d) dns, _ := h.domainDNS(d, record, d.DMARCRua, false)
if err != nil {
logf("panel: domains: domain %d: dmarc report address: %v", d.ID, err)
return
}
dns, _ := h.domainDNS(d, record, reportEmail, false)
rows[i].DNS = dnsTags(dns.DKIM.Status, dns.SPF.Status, dns.DMARC.Status) 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 // OpenDKIM reload), and redirects to the domain's page so the DNS record to
// publish is shown (product.md). // publish is shown (product.md).
func (h *Handlers) HandleAddDomain(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleAddDomain(w http.ResponseWriter, r *http.Request) {
p, ok := h.requireGlobal(w, r) _, ok := h.requireGlobal(w, r)
if !ok { if !ok {
return 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) "Could not add the domain. Please check the logs and try again.", raw)
return return
} }
h.followCreator(d.ID, p.ID)
http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d", d.ID), http.StatusSeeOther) 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. // HandleDeleteConfirm shows the cascade warning before a domain is removed.
func (h *Handlers) HandleDeleteConfirm(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleDeleteConfirm(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok { if _, ok := h.requireGlobal(w, r); !ok {
@@ -95,7 +95,7 @@ func TestDomainPageShowsRecordsApplicationsAndConnection(t *testing.T) {
has(t, "domain", body, has(t, "domain", body,
`<h1 class="title is-3">example.org</h1>`, `class="sp-postmark`, `<h3>DKIM</h3>`, `<h3>SPF</h3>`, `<h3>DMARC</h3>`, `<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 `, `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>`, `href="/outbound/domains/1/applications/`+idStr(a.ID)+`">Edit</a>`,
`mail.example.org`, `STARTTLS (submission)`, `action="/outbound/domains/1/dns-recheck"`, `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"`) `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>`) lacks(t, "domain", body, `<th>Login</th>`)
} }
// The report address form offers what the viewer can choose, and says whose // The rest of the settings page, beside the report address (the three choices
// default the domain follows. // are in handlers_dmarc_test.go).
func TestDomainSettingsReportAddressChoices(t *testing.T) { func TestDomainSettingsPageHasItsBoxes(t *testing.T) {
s := newAppStand(t) s := newAppStand(t)
body := pageOf(t, s.h.HandleDomainSettings, "/outbound/domains/1/settings") body := pageOf(t, s.h.HandleDomainSettings, "/outbound/domains/1/settings")
has(t, "settings", body, `<h1 class="title is-3">Domain settings</h1>`, 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>`, `action="/outbound/domains/1/settings/reports"`, `<option value="none" selected>No reports</option>`, `<option value="custom">A specific address</option>`,
`<option value="none" selected>`, `<option value="custom">`,
`action="/outbound/domains/1/settings/ratelimit"`, `action="/outbound/domains/1/settings/export"`, `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.`) `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`) 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) { func TestDomainSettingsShowsTheRateLimit(t *testing.T) {
@@ -216,7 +172,7 @@ func TestDomainSettingsRefusalsStayOnTheSettingsPage(t *testing.T) {
lacks(t, "report address refusal", body, `<b>`) lacks(t, "report address refusal", body, `<b>`)
code, body = post(s.h.HandleDomainDMARC, "reports", url.Values{"dmarc_rua_mode": {"custom"}}) 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"}}) code, body = post(s.h.HandleDomainDMARC, "reports", url.Values{"dmarc_rua_mode": {"hosted"}})
if code != http.StatusBadRequest { if code != http.StatusBadRequest {
@@ -6,10 +6,7 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/mixeme/selfpost/internal/dmarc"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store" "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, // 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) 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) after, _ := st.GetUser(p.ID)
if after.Username != before.Username || after.PasswordHash != before.PasswordHash || after.Role != before.Role || 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()) { !equalReach(after.Reach(), before.Reach()) {
t.Errorf("saving untouched changed the user:\nbefore %+v\nafter %+v", before, after) 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) logf("panel: overview: domain %d: dkim record: %v", d.ID, err)
return return
} }
reportEmail, err := h.domainReportAddress(d) dns, _ := h.domainDNS(d, record, d.DMARCRua, false)
if err != nil {
logf("panel: overview: domain %d: dmarc report address: %v", d.ID, err)
return
}
dns, _ := h.domainDNS(d, record, reportEmail, false)
rows[i].DNS = dnsTags(dns.DKIM.Status, dns.SPF.Status, dns.DMARC.Status) 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}) 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 return
} }
h.resyncAfterEmailChange(u, email)
if password != "" { if password != "" {
h.endSessionsAfterPasswordSet(r, p, u) h.endSessionsAfterPasswordSet(r, p, u)
} }
@@ -384,18 +383,6 @@ func (h *Handlers) HandleUserDeleteConfirm(w http.ResponseWriter, r *http.Reques
return 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)) h.view.Render(w, http.StatusOK, "user-delete", view.NewUserDelete(h.shellMeta(r), in))
} }
+12 -110
View File
@@ -3,18 +3,14 @@ package view
// The data of Account (templates/account.html, mockup account.html) and of // The data of Account (templates/account.html, mockup account.html) and of
// Server › Settings (templates/settings.html, mockup settings.html): what used // Server › Settings (templates/settings.html, mockup settings.html): what used
// to be one Settings page, split by whose it is. Account is the signed-in // to be one Settings page, split by whose it is. Account is the signed-in
// user's own — who they are to the panel, their password and where DMARC reports // user's own — who they are to the panel and their password — and every role
// about their domains go — and every role has it. Settings is the instance's. // has it. Settings is the instance's.
import ( import "strconv"
"strconv"
"strings"
)
// Account is the data of the Account page. Three forms, each its own POST: the // Account is the data of the Account page. Two forms, each its own POST: the
// profile (Username, Email), the password, and the default report address // profile (Username, Email) and the password. The fields hold what was stored,
// (DMARCSelected, DMARCAddress). The form fields hold what was stored, or what // or what was just submitted when a form is shown again with an error.
// was just submitted when a form is shown again with an error.
type Account struct { type Account struct {
Meta Meta
Head Head Head Head
@@ -25,122 +21,28 @@ type Account struct {
Email string Email string
Password Box Password Box
DMARC Box
DMARCOptions []Option
DMARCSelected string
DMARCAddress string
DMARCHelp Text
hosted bool
// Authorization is the report-authorization record the default address
// needs when it is on another domain than the senders; nil when it needs
// none (the hosted address, no reports, an address on the senders' domain).
Authorization *Record
} }
// DMARC default choices, the values of the select (store.DMARCDefault*).
const (
DMARCChoiceHosted = "hosted"
DMARCChoiceAccount = "account"
DMARCChoiceCustom = "custom"
DMARCChoiceNone = "none"
)
// NewAccount builds the page for a user. role is "global" or "domain" and // NewAccount builds the page for a user. role is "global" or "domain" and
// appears in the kicker. hosted says whether SelfPost-hosted reports are // appears in the kicker.
// offered (report ingest is on), accountEmail is the e-mail the "my account func NewAccount(m Meta, role string) *Account {
// e-mail" choice names.
func NewAccount(m Meta, role string, hosted bool, accountEmail string) *Account {
m.Title, m.Section, m.Page = "Account", "user", "account" m.Title, m.Section, m.Page = "Account", "user", "account"
a := &Account{ return &Account{
Meta: m, Meta: m,
Head: Head{ Head: Head{
Kicker: "Signed in as " + m.User + " · " + role, Kicker: "Signed in as " + m.User + " · " + role,
Title: "Account", Title: "Account",
Lead: Plain("Who you are to this panel and where reports about your domains go. Applications keep their own logins and passwords and are not affected."), Lead: Plain("Who you are to this panel. Applications keep their own logins and passwords and are not affected."),
}, },
Profile: Box{No: "01", Title: "Profile"}, Profile: Box{No: "01", Title: "Profile"},
Password: Box{No: "02", Title: "Password"}, Password: Box{No: "02", Title: "Password"},
DMARC: Box{No: "03", Title: "DMARC reports", ID: "dmarc",
Help: &HelpLink{Href: "/help#" + HelpDNS, Title: "DMARC and where its reports go"}},
hosted: hosted,
} }
a.WithDomainUse(0, 0)
if hosted {
a.DMARCOptions = append(a.DMARCOptions, Option{Value: DMARCChoiceHosted, Label: "SelfPost hosted — read them under DMARC reports"})
}
account := "My account e-mail"
if accountEmail != "" {
account += " — " + accountEmail
}
a.DMARCOptions = append(a.DMARCOptions,
Option{Value: DMARCChoiceAccount, Label: account},
Option{Value: DMARCChoiceCustom, Label: "Another address…"},
Option{Value: DMARCChoiceNone, Label: "No reports"},
)
return a
}
// WithDomainUse says how many of the user's outbound domains follow their
// default address now (of total); nothing is said when they have none.
func (a *Account) WithDomainUse(following, total int) *Account {
help := Rich("Used by every outbound domain whose report address is set to ", Em(a.Meta.User+"'s default"))
if total > 0 {
help = append(help, Inline{Text: " — " + strconv.Itoa(following) + " of " + strconv.Itoa(total) + " now"})
}
help = append(help, Inline{Text: ". Changing it changes the DMARC record those domains must publish. "})
if a.hosted {
help = append(help, Inline{Text: "The hosted address is parsed by SelfPost; any other just receives the raw XML."})
} else {
help = append(help, Inline{Text: "The address receives the raw XML."})
}
a.DMARCHelp = help
return a
} }
// WithResult shows the result of the last action, or its refusal, between the // WithResult shows the result of the last action, or its refusal, between the
// head and the first box. // head and the first box.
func (a *Account) WithResult(flash, formErr string) *Account { func (a *Account) WithResult(flash, formErr string) *Account {
switch { a.Flash = resultFlash(flash, formErr)
case formErr != "":
a.Flash = &Flash{Error: true, Text: Plain(formErr)}
case flash != "":
a.Flash = &Flash{Text: Plain(flash)}
}
return a
}
// WithAuthorization adds the report-authorization record the default address
// needs on its own domain: where to publish it (host, value) and what DNS said
// about it — a status ("ok", "warn", "error" or "unknown"), the sentence that
// explains it and the records found. The tag in the box head repeats the
// verdict.
func (a *Account) WithAuthorization(host, value, status, detail string, found []string) *Account {
rec := &Record{
Name: "Report authorization",
Note: Plain("Needed because the address is on another domain than the senders"),
Host: host, Type: "TXT", Value: value,
}
switch status {
case "ok":
rec.Status = Tag{Status: "published"}
a.DMARC.End = Rich(TagOf("ok", "authorized"))
case "warn", "error":
level := map[string]string{"warn": "warn", "error": "fail"}[status]
rec.Status = Tag{Status: level, Label: "not published"}
if len(found) > 0 {
rec.InDNS = strings.Join(found, " ")
rec.Problem = Plain(detail)
} else {
rec.Note = Plain(detail)
}
a.DMARC.End = Rich(TagOf(level, "not authorized"))
case "unknown":
rec.Status = Tag{Status: "unknown", Label: "not checked"}
rec.Note = Plain(detail)
a.DMARC.End = Rich(TagOf("unknown", "not checked"))
}
a.Authorization = rec
return a return a
} }
@@ -169,7 +71,7 @@ func NewSettings(m Meta, retention, level1 string) *Settings {
Head: Head{ Head: Head{
Kicker: "Server", Kicker: "Server",
Title: "Settings", Title: "Settings",
Lead: Rich("What holds for the whole instance, whoever is signed in. Your own name, e-mail and DMARC report address are under ", Lead: Rich("What holds for the whole instance, whoever is signed in. Your own name, e-mail and password are under ",
Link("/account", "Account"), "."), Link("/account", "Account"), "."),
}, },
Retention: Box{No: "01", Title: "Log retention", Help: topicLink(HelpSettings, "How long rows are kept")}, Retention: Box{No: "01", Title: "Log retention", Help: topicLink(HelpSettings, "How long rows are kept")},
+12
View File
@@ -198,6 +198,18 @@ type Action struct {
Danger bool Danger bool
} }
// RowAction is an action that changes something, in the last cell of a table
// row (partial row_action; used by the Applications table of a domain). A link
// cannot POST, so it is a form with one button that the stylesheet makes read as
// the links beside it: Post is the address it submits to, Confirm the question
// panel.js asks first (data-confirm), Danger the red of a delete.
type RowAction struct {
Label string
Post string
Confirm string
Danger bool
}
// Postmark is the round verdict stamp inside a Head: three lines of imprint // Postmark is the round verdict stamp inside a Head: three lines of imprint
// and the Level (ok, warn or fail) that colours it. // and the Level (ok, warn or fail) that colours it.
type Postmark struct { type Postmark struct {
+16 -19
View File
@@ -120,15 +120,12 @@ func healthFixture(refresh bool) *Health {
return h return h
} }
// accountFixture is the mockup's Account: the global administrator, their // accountFixture is the mockup's Account: the global administrator, their name
// e-mail as the DMARC default, and the authorization record that default needs. // and e-mail.
func accountFixture() *Account { func accountFixture() *Account {
m := admin() a := NewAccount(admin(), "global")
a := NewAccount(m, "global", true, "mix@example.org")
a.Username, a.Email = "admin", "mix@example.org" a.Username, a.Email = "admin", "mix@example.org"
a.DMARCSelected = DMARCChoiceAccount return a
a.WithDomainUse(2, 3)
return a.WithAuthorization("*._report._dmarc.example.org", "v=DMARC1;", "ok", "", nil)
} }
// settingsFixture is the mockup's Settings: thirty days of log, a limit of 600 // settingsFixture is the mockup's Settings: thirty days of log, a limit of 600
@@ -166,15 +163,17 @@ func outDomainFixture() *OutDomain {
SPFRecord("example.org", "v=spf1 ip4:203.0.113.25 ~all", DNSCheck{Status: "error", SPFRecord("example.org", "v=spf1 ip4:203.0.113.25 ~all", DNSCheck{Status: "error",
Detail: "The published record does not authorize 203.0.113.25. Add ip4:203.0.113.25 before ~all.", Detail: "The published record does not authorize 203.0.113.25. Add ip4:203.0.113.25 before ~all.",
Found: []string{"v=spf1 include:_spf.google.com ~all"}}), Found: []string{"v=spf1 include:_spf.google.com ~all"}}),
DMARCRecord(DMARCRecordInput{Host: "_dmarc.example.org", Value: "v=DMARC1; p=none; rua=mailto:dmarc@mail.example.org", DMARCRecord(DMARCRecordInput{Host: "_dmarc.example.org", Value: "v=DMARC1; p=none; rua=mailto:example.org@dmarc.mail.example.org",
Check: DNSCheck{Status: "ok"}, Source: "admin's default", SettingsHref: "/outbound/domains/1/settings", Check: DNSCheck{Status: "ok"}, Source: "example.org@dmarc.mail.example.org", SettingsHref: "/outbound/domains/1/settings",
ReportsHref: "/outbound/dmarc/domains/1"}), ReportsHref: "/outbound/dmarc/domains/1"}),
}, "3 min ago") }, "3 min ago")
p.WithApplications([]OutAppRow{ p.WithApplications([]OutAppRow{
{Login: "prod-server", Senders: []string{"*@example.org"}, Activity: FormatActivity(1102, 96), {Login: "prod-server", Senders: []string{"*@example.org"}, Activity: FormatActivity(1102, 96),
Limits: []Tag{{Status: "ok", Label: "200 / h"}}, Edit: "/outbound/domains/1/applications/1"}, Limits: []Tag{{Status: "ok", Label: "200 / h"}}, Edit: "/outbound/domains/1/applications/1",
Actions: AppRowActions(1, 1, "prod-server")},
{Login: "alerts", Senders: []string{"alerts@example.org", "noc@example.org"}, Activity: FormatActivity(182, 14), {Login: "alerts", Senders: []string{"alerts@example.org", "noc@example.org"}, Activity: FormatActivity(182, 14),
Limits: []Tag{{Label: "domain"}, {Status: "ok", Label: "2 IPs"}}, Edit: "/outbound/domains/1/applications/2"}, Limits: []Tag{{Label: "domain"}, {Status: "ok", Label: "2 IPs"}}, Edit: "/outbound/domains/1/applications/2",
Actions: AppRowActions(1, 2, "alerts")},
}) })
p.WithConnection("mail.example.org", true) p.WithConnection("mail.example.org", true)
p.WithSeeAlso("/outbound/log?domain=example.org", "/outbound/dmarc/domains/1") p.WithSeeAlso("/outbound/log?domain=example.org", "/outbound/dmarc/domains/1")
@@ -182,17 +181,15 @@ func outDomainFixture() *OutDomain {
return p return p
} }
// outDomainSettingsFixture is the mockup's Domain settings: reports go to the // outDomainSettingsFixture is the mockup's Domain settings: reports go to an
// default of the signed-in administrator, the rate limit is automatic. // address typed for the domain, the rate limit is automatic.
func outDomainSettingsFixture() *OutDomainSettings { func outDomainSettingsFixture() *OutDomainSettings {
p := NewOutDomainSettings(admin(), 1, "example.org", 2, true) p := NewOutDomainSettings(admin(), 1, "example.org", 2, true)
p.WithReportAddress([]Option{ p.WithReportAddress([]Option{
{Value: "inherit", Label: "admin's default — mix@example.org"}, {Value: ReportNone, Label: "No reports"},
{Value: "hosted", Label: "SelfPost hosted (example.org@dmarc.mail.example.org)"}, {Value: ReportHosted, Label: "SelfPost hosted — example.org@dmarc.mail.example.org"},
{Value: "none", Label: "No aggregate reports"}, {Value: ReportCustom, Label: "A specific address"},
{Value: "custom", Label: "Custom address"}, }, ReportCustom, "reports@acme.io", "mix@example.org")
}, "inherit", "", Rich("A default belongs to a user and is set under their ", 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."))
p.WithExport(12) p.WithExport(12)
return p.WithRateLimit(DomainRateLimit{ return p.WithRateLimit(DomainRateLimit{
Active: true, Auto: true, MaxMessages: "288", Window: "3600", Multiplier: "2.5", Active: true, Auto: true, MaxMessages: "288", Window: "3600", Multiplier: "2.5",
+2
View File
@@ -31,6 +31,7 @@ type Kit struct {
Records []Record Records []Record
Tables Box Tables Box
TablesFoot Foot TablesFoot Foot
RowAction RowAction
LogPane Box LogPane Box
LogLines []LogLine LogLines []LogLine
Facts Box Facts Box
@@ -123,6 +124,7 @@ func KitPage() *Kit {
Tables: Box{No: "07", Title: "Table conventions", End: Rich("Always inside ", Code("table-container"), " · an address breaks after its @")}, Tables: Box{No: "07", Title: "Table conventions", End: Rich("Always inside ", Code("table-container"), " · an address breaks after its @")},
TablesFoot: Foot{Text: Plain("Page 1 of 9"), Link: &Anchor{Href: "#", Label: "Older →"}}, TablesFoot: Foot{Text: Plain("Page 1 of 9"), Link: &Anchor{Href: "#", Label: "Older →"}},
RowAction: RowAction{Label: "Delete", Post: "#", Danger: true},
LogPane: Box{No: "09", Title: "log_pane", End: Plain("sp-d time · sp-w warning · sp-e error")}, LogPane: Box{No: "09", Title: "log_pane", End: Plain("sp-d time · sp-w warning · sp-e error")},
LogLines: []LogLine{ LogLines: []LogLine{
+35 -12
View File
@@ -164,7 +164,7 @@ func SPFRecord(domain, value string, c DNSCheck) Record {
} }
// DMARCRecordInput is what a DMARC record needs besides its check: where the // DMARCRecordInput is what a DMARC record needs besides its check: where the
// reports go in words (Source, "admin's default") and the page that changes it, // reports go in words (Source: the address, or "no reports") and the page that changes it,
// the page of the reports themselves ("" when the panel has none), and whether // the page of the reports themselves ("" when the panel has none), and whether
// the report address is on the sending domain with nobody to read it there. // the report address is on the sending domain with nobody to read it there.
type DMARCRecordInput struct { type DMARCRecordInput struct {
@@ -316,10 +316,10 @@ type OutDomain struct {
Apps Box Apps Box
// Days is the length of the send window of the "N days" column. // Days is the length of the send window of the "N days" column.
Days int Days int
AppRows []OutAppRow AppRows []OutAppRow
AppsEmpty EmptyState AppsEmpty EmptyState
AppsLegend Foot AppsFoot Foot
Connection Box Connection Box
ConnFacts []Fact ConnFacts []Fact
@@ -332,15 +332,15 @@ type OutDomain struct {
// OutAppRow is one line of the Applications table. Senders are the addresses // OutAppRow is one line of the Applications table. Senders are the addresses
// the login may send as (*@example.org for the whole domain); Limits are tags, // the login may send as (*@example.org for the whole domain); Limits are tags,
// the application's own rate limit or "domain" and its client IP allow-list. // the application's own rate limit or "domain" and its client IP allow-list.
// Edit leads to the application's page, where a new password and deleting it // Edit leads to the application's page; New password and Delete are POSTs in
// are one click away: both are POSTs, and a row of a table has no control for // the row itself (RowAction), each asking before it is sent.
// those (only links).
type OutAppRow struct { type OutAppRow struct {
Login string Login string
Senders []string Senders []string
Activity string Activity string
Limits []Tag Limits []Tag
Edit string Edit string
Actions []RowAction
} }
// NewOutDomain builds the page of a domain. canDelete is the global role: it // NewOutDomain builds the page of a domain. canDelete is the global role: it
@@ -364,6 +364,7 @@ func NewOutDomain(m Meta, id int64, name string, canDelete bool) *OutDomain {
Apps: Box{No: "02", Title: "Applications", ID: "apps", End: Plain("SASL logins that may send as this domain"), Apps: Box{No: "02", Title: "Applications", ID: "apps", End: Plain("SASL logins that may send as this domain"),
Help: topicLink(HelpApps, "What an application is")}, Help: topicLink(HelpApps, "What an application is")},
AppsEmpty: EmptyState{Icon: "ti-apps", Text: Plain("No applications yet. Add one to let a program send as this domain.")}, AppsEmpty: EmptyState{Icon: "ti-apps", Text: Plain("No applications yet. Add one to let a program send as this domain.")},
AppsFoot: Foot{Bare: true, Text: Plain("New password and Delete ask for confirmation; the old password stops working at once.")},
Connection: Box{No: "03", Title: "Connection", ID: "connection", End: Plain("The same for every domain")}, Connection: Box{No: "03", Title: "Connection", ID: "connection", End: Plain("The same for every domain")},
} }
} }
@@ -529,6 +530,9 @@ type OutDomainSettings struct {
RuaSelected string RuaSelected string
RuaCustom string RuaCustom string
RuaHelp Text RuaHelp Text
// RuaMine is the signed-in user's profile e-mail, which the button beside
// the typed address fills the field with; "" leaves the button out.
RuaMine string
Export Box Export Box
ExportAction string ExportAction string
@@ -638,11 +642,30 @@ func NewOutDomainSettings(m Meta, id int64, name string, apps int, canDelete boo
return p return p
} }
// The three choices of a domain's report address, the values of the select: no
// reports, the address SelfPost hosts for the domain (offered only while hosted
// reports are enabled) and an address typed for the domain.
const (
ReportNone = "none"
ReportHosted = "hosted"
ReportCustom = "custom"
)
// WithReportAddress sets the report-address form: the choices (the value of an // WithReportAddress sets the report-address form: the choices (the value of an
// option is what the handler reads), the one selected, the custom address // option is what the handler reads), the one selected, the address typed for
// typed, and the sentence under the select. // the domain, and the profile e-mail of the viewer that the button beside the
func (p *OutDomainSettings) WithReportAddress(options []Option, selected, custom string, help Text) *OutDomainSettings { // field fills it with ("" for none). The sentence under the select says what
p.RuaOptions, p.RuaSelected, p.RuaCustom, p.RuaHelp = options, selected, custom, help // each choice offered means.
func (p *OutDomainSettings) WithReportAddress(options []Option, selected, custom, mine string) *OutDomainSettings {
p.RuaOptions, p.RuaSelected, p.RuaCustom, p.RuaMine = options, selected, custom, mine
help := Rich("No reports leaves ", Code("rua="), " out of the DMARC record. ")
for _, o := range options {
if o.Value == ReportHosted {
help = append(help, Inline{Text: "SelfPost hosted sends them to an address on this server, which parses them for DMARC reports. "})
}
}
help = append(help, Inline{Text: "A specific address is any mailbox, and it receives the raw XML. Changing this changes the DMARC record to publish."})
p.RuaHelp = help
return p return p
} }
+23 -4
View File
@@ -154,6 +154,27 @@ func NewOutApp(m Meta, domainID int64, domain, login string) *OutApp {
return p return p
} }
// newPasswordQuestion and deleteAppQuestion are what panel.js asks before an
// application's password is replaced or the application is removed — the same
// words in the application's head and in its row of the domain's table.
func newPasswordQuestion(login string) string {
return "Generate a new password for " + login + "? The current password stops working immediately."
}
func deleteAppQuestion(login string) string {
return "Delete application " + login + "? Its credentials stop working immediately."
}
// AppRowActions are New password and Delete in an application's row of the
// domain's table: the POSTs of the application's own page, with its questions.
func AppRowActions(domainID, id int64, login string) []RowAction {
own := DomainHref(domainID) + "/applications/" + strconv.FormatInt(id, 10)
return []RowAction{
{Label: "New password", Post: own + "/password", Confirm: newPasswordQuestion(login)},
{Label: "Delete", Post: own + "/delete", Danger: true, Confirm: deleteAppQuestion(login)},
}
}
// WithApplication points an edit form at its application: the POST address, the // WithApplication points an edit form at its application: the POST address, the
// head's buttons (a new password and delete, each asking first) and the mail of // head's buttons (a new password and delete, each asking first) and the mail of
// the statistics window under the title. // the statistics window under the title.
@@ -162,10 +183,8 @@ func (p *OutApp) WithApplication(domainID, id int64, total, peak int64, avg stri
p.Action = own p.Action = own
p.Head.Lead = StatsLead(total, peak, avg, days) p.Head.Lead = StatsLead(total, peak, avg, days)
p.Head.Actions = []Action{ p.Head.Actions = []Action{
{Label: "New password", Icon: "ti-key", Post: own + "/password", {Label: "New password", Icon: "ti-key", Post: own + "/password", Confirm: newPasswordQuestion(p.Head.Title)},
Confirm: "Generate a new password for " + p.Head.Title + "? The current password stops working immediately."}, {Label: "Delete", Icon: "ti-trash", Post: own + "/delete", Danger: true, Confirm: deleteAppQuestion(p.Head.Title)},
{Label: "Delete", Icon: "ti-trash", Post: own + "/delete", Danger: true,
Confirm: "Delete application " + p.Head.Title + "? Its credentials stop working immediately."},
} }
p.recalc = own + "/ratelimit/recalc" p.recalc = own + "/ratelimit/recalc"
return p return p
+34 -9
View File
@@ -99,7 +99,7 @@ func TestOutDomainRecordsSayWhatDNSHolds(t *testing.T) {
`sp2026._domainkey.example.org`, `<textarea class="textarea" rows="3" readonly>`, `sp2026._domainkey.example.org`, `<textarea class="textarea" rows="3" readonly>`,
`<h3>SPF</h3>`, `<span class="tag is-danger is-light">mismatch</span>`, `v=spf1 ip4:203.0.113.25 ~all`, `<h3>SPF</h3>`, `<span class="tag is-danger is-light">mismatch</span>`, `v=spf1 ip4:203.0.113.25 ~all`,
`In DNS now`, `v=spf1 include:_spf.google.com ~all`, `Shallow check: <code>include:</code> is not followed.`, `In DNS now`, `v=spf1 include:_spf.google.com ~all`, `Shallow check: <code>include:</code> is not followed.`,
`<h3>DMARC</h3>`, `Report address: <a href="/outbound/domains/1/settings#reports">admin&#39;s default</a>`, `<h3>DMARC</h3>`, `Report address: <a href="/outbound/domains/1/settings#reports">example.org@dmarc.mail.example.org</a>`,
`<code>p=none</code> does not affect delivery.`, `<a href="/outbound/dmarc/domains/1">reports</a>`, `<code>p=none</code> does not affect delivery.`, `<a href="/outbound/dmarc/domains/1">reports</a>`,
) )
if got := strings.Count(out, `In DNS now`); got != 1 { if got := strings.Count(out, `In DNS now`); got != 1 {
@@ -115,7 +115,7 @@ func TestOutDomainRecordsSayWhatDNSHolds(t *testing.T) {
DKIMRecord("sp2026", "sp2026._domainkey.example.org", "v=DKIM1; p=KEY", DNSCheck{Status: "error", Detail: "No DKIM record was found."}), DKIMRecord("sp2026", "sp2026._domainkey.example.org", "v=DKIM1; p=KEY", DNSCheck{Status: "error", Detail: "No DKIM record was found."}),
SPFRecord("example.org", "v=spf1 -all", DNSCheck{Status: "warn", Detail: "The record ends in ~all.", Found: []string{"v=spf1 ~all"}}), SPFRecord("example.org", "v=spf1 -all", DNSCheck{Status: "warn", Detail: "The record ends in ~all.", Found: []string{"v=spf1 ~all"}}),
DMARCRecord(DMARCRecordInput{Host: "_dmarc.example.org", Value: "v=DMARC1; p=none", Check: DNSCheck{Status: "unknown", Detail: "DNS could not be reached."}, DMARCRecord(DMARCRecordInput{Host: "_dmarc.example.org", Value: "v=DMARC1; p=none", Check: DNSCheck{Status: "unknown", Detail: "DNS could not be reached."},
Source: "none", SettingsHref: "/outbound/domains/1/settings", SameDomain: true}), Source: "no reports", SettingsHref: "/outbound/domains/1/settings", SameDomain: true}),
ReportAuthRecord("_report._dmarc.hub.example", "v=DMARC1;", DNSCheck{Status: "error", Detail: "Not authorized."}), ReportAuthRecord("_report._dmarc.hub.example", "v=DMARC1;", DNSCheck{Status: "error", Detail: "Not authorized."}),
} }
out = renderSignedIn(t, "out-domain", outDomainFixture().WithRecords(records, "just now")) out = renderSignedIn(t, "out-domain", outDomainFixture().WithRecords(records, "just now"))
@@ -133,14 +133,22 @@ func TestOutDomainApplicationsTable(t *testing.T) {
out := renderSignedIn(t, "out-domain", outDomainFixture()) out := renderSignedIn(t, "out-domain", outDomainFixture())
pageHas(t, "Domain", out, pageHas(t, "Domain", out,
`<th>Login</th><th>May send as</th><th>30 days</th><th>Limits</th>`, `<th>Login</th><th>May send as</th><th>30 days</th><th>Limits</th>`,
`<td class="sp-mono"><strong>prod-server</strong></td>`, `*@<wbr>example.org`, `<td class="sp-mono sp-nowrap"><strong>prod-server</strong></td>`, `*@<wbr>example.org`,
`alerts@<wbr>example.org, noc@<wbr>example.org`, "1 102 msg · peak 96/h", `alerts@<wbr>example.org, noc@<wbr>example.org`, "1 102 msg · peak 96/h",
`<span class="tag is-success is-light">200 / h</span>`, `<span class="tag is-light">domain</span> <span class="tag is-success is-light">2 IPs</span>`, `<span class="tag is-success is-light">200 / h</span>`, `<span class="tag is-light">domain</span> <span class="tag is-success is-light">2 IPs</span>`,
`<a href="/outbound/domains/1/applications/2">Edit</a>`, `<a href="/outbound/domains/1/applications/2">Edit</a>`,
) )
// A state change is never a link, and a table row has no control for one: // A state change is never a link: New password and Delete are POST forms in
// a new password and deleting an application are on the application's page. // the row, each with the question panel.js asks first, drawn like the Edit
pageLacks(t, "Domain", out, `/password`, `/applications/1/delete`) // link beside them. Edit leads on to the application's page.
pageHas(t, "Domain", out,
`<td class="sp-actions"><a href="/outbound/domains/1/applications/1">Edit</a>`+
`<form method="post" action="/outbound/domains/1/applications/1/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/1/delete" data-confirm="Delete application prod-server? Its credentials stop working immediately."><button type="submit" class="has-text-danger">Delete</button></form></td>`,
`action="/outbound/domains/1/applications/2/password" data-confirm="Generate a new password for alerts?`,
`action="/outbound/domains/1/applications/2/delete" data-confirm="Delete application alerts?`,
`New password and Delete ask for confirmation; the old password stops working at once.`,
)
p := outDomainFixture().WithApplications(nil) p := outDomainFixture().WithApplications(nil)
out = renderSignedIn(t, "out-domain", p) out = renderSignedIn(t, "out-domain", p)
@@ -210,8 +218,11 @@ func TestOutDomainSettingsHasAFormPerBox(t *testing.T) {
`<a href="/outbound/domains">Outbound domains</a> /`, `<a href="/outbound/domains/1">example.org</a> /`, `<a href="/outbound/domains">Outbound domains</a> /`, `<a href="/outbound/domains/1">example.org</a> /`,
`id="reports"`, `id="limit"`, `id="export"`, `id="reports"`, `id="limit"`, `id="export"`,
`action="/outbound/domains/1/settings/reports"`, `name="dmarc_rua_mode"`, `data-custom-mode="custom"`, `data-custom-address`, `action="/outbound/domains/1/settings/reports"`, `name="dmarc_rua_mode"`, `data-custom-mode="custom"`, `data-custom-address`,
`name="dmarc_rua_email"`, `<option value="inherit" selected>admin&#39;s default — mix@example.org</option>`, `name="dmarc_rua_email"`, `value="reports@acme.io"`, `<option value="none">No reports</option>`,
`<option value="hosted">`, `<option value="none">`, `<option value="custom">`, `href="/account#dmarc"`, `<option value="hosted">SelfPost hosted — example.org@dmarc.mail.example.org</option>`,
`<option value="custom" selected>A specific address</option>`,
`<button type="button" class="button" data-fill="rua_email" data-fill-value="mix@example.org"><i class="ti ti-mail"></i>Use my e-mail</button>`,
`SelfPost hosted sends them to an address on this server`, `Changing this changes the DMARC record to publish.`,
`action="/outbound/domains/1/settings/export"`, `name="encrypt" value="1" checked data-encrypt-toggle`, `data-encrypt-fields`, `action="/outbound/domains/1/settings/export"`, `name="encrypt" value="1" checked data-encrypt-toggle`, `data-encrypt-fields`,
`name="password"`, `name="password_confirm"`, `minlength="12"`, `at least 12 characters`, `name="password"`, `name="password_confirm"`, `minlength="12"`, `at least 12 characters`,
`action="/outbound/domains/1/settings/ratelimit"`, `name="mode"`, `data-ratelimit-mode`, `data-manual-fields`, `data-auto-fields`, `action="/outbound/domains/1/settings/ratelimit"`, `name="mode"`, `data-ratelimit-mode`, `data-manual-fields`, `data-auto-fields`,
@@ -260,11 +271,25 @@ func TestOutDomainSettingsStatesTheFixtureDoesNotDraw(t *testing.T) {
pageLacks(t, "Settings", out, `Delete domain`, `/delete`) pageLacks(t, "Settings", out, `Delete domain`, `/delete`)
// A refusal comes back as the flash, with the custom address that was typed. // A refusal comes back as the flash, with the custom address that was typed.
p = outDomainSettingsFixture().WithReportAddress([]Option{{Value: "custom", Label: "Custom address"}}, "custom", `x"><i>`, nil). p = outDomainSettingsFixture().WithReportAddress([]Option{{Value: "custom", Label: "A specific address"}}, "custom", `x"><i>`, "").
WithResult("", "Enter a valid e-mail address.") WithResult("", "Enter a valid e-mail address.")
out = renderSignedIn(t, "out-domain-settings", p) out = renderSignedIn(t, "out-domain-settings", p)
pageHas(t, "Settings", out, `<div class="notification is-danger is-light">`, `Enter a valid e-mail address.`, `<option value="custom" selected>`) pageHas(t, "Settings", out, `<div class="notification is-danger is-light">`, `Enter a valid e-mail address.`, `<option value="custom" selected>`)
pageLacks(t, "Settings", out, `<i>`) pageLacks(t, "Settings", out, `<i>`)
// Without a profile e-mail there is nothing to fill with: no button. Without
// hosted reports on the server the sentence about them is not there either.
pageLacks(t, "Settings", out, `data-fill`, `Use my e-mail`, `SelfPost hosted`)
}
// The fill button carries the viewer's profile e-mail escaped, and only when
// there is one.
func TestReportAddressFillButton(t *testing.T) {
opts := []Option{{Value: ReportNone, Label: "No reports"}, {Value: ReportCustom, Label: "A specific address"}}
out := renderSignedIn(t, "out-domain-settings", outDomainSettingsFixture().WithReportAddress(opts, ReportNone, "", `a"><i>@x.example`))
pageHas(t, "Settings", out, `data-fill="rua_email" data-fill-value="a&#34;&gt;&lt;i&gt;@x.example"`)
pageLacks(t, "Settings", out, `<i>@`)
out = renderSignedIn(t, "out-domain-settings", outDomainSettingsFixture().WithReportAddress(opts, ReportNone, "", ""))
pageLacks(t, "Settings", out, `data-fill`, `Use my e-mail`)
} }
func TestDeleteBoxNamesTheApplicationsItTakes(t *testing.T) { func TestDeleteBoxNamesTheApplicationsItTakes(t *testing.T) {
+14 -46
View File
@@ -250,57 +250,25 @@ func TestNewHealthFlash(t *testing.T) {
// ---- Account // ---- Account
func TestAccountHasThreeFormsEachItsOwnPost(t *testing.T) { func TestAccountHasTwoFormsEachItsOwnPost(t *testing.T) {
out := renderSignedIn(t, "account", accountFixture()) out := renderSignedIn(t, "account", accountFixture())
pageHas(t, "Account", out, pageHas(t, "Account", out,
`<h1 class="title is-3">Account</h1>`, `Signed in as admin · global`, `<h1 class="title is-3">Account</h1>`, `Signed in as admin · global`,
`action="/account/profile"`, `name="username"`, `name="email"`, `value="mix@example.org"`, `action="/account/profile"`, `name="username"`, `name="email"`, `value="mix@example.org"`,
`action="/account/password"`, `name="current_password"`, `name="new_password"`, `name="new_password_confirm"`, `action="/account/password"`, `name="current_password"`, `name="new_password"`, `name="new_password_confirm"`,
`action="/account/dmarc"`, `name="dmarc_default"`, `name="dmarc_default_address"`, `data-custom-mode="custom"`, `data-custom-address`, `Your own address. A domain's report address can be filled with it from Domain settings.`,
`<option value="hosted">`, `<option value="account" selected>My account e-mail — mix@example.org</option>`,
`<option value="custom">`, `<option value="none">`,
`2 of 3 now`, `<em>admin&#39;s default</em>`,
) )
pageLacks(t, "Account", out, `send_log_retention_days`) // Account is Profile and Password; where a domain's reports go is the
// No form asks for another's fields: three forms, three submit buttons. // domain's own setting.
if got := strings.Count(out, `<form class="sp-form" method="post"`); got != 3 { pageLacks(t, "Account", out, `send_log_retention_days`, `/account/dmarc`, `dmarc_default`, `<h2>DMARC reports</h2>`,
t.Errorf("Account has %d forms, want 3", got) `Report authorization`, `default report address`, `id="dmarc"`)
// No form asks for another's fields: two forms, two submit buttons.
if got := strings.Count(out, `<form class="sp-form" method="post"`); got != 2 {
t.Errorf("Account has %d forms, want 2", got)
} }
} // Two equal columns, one box each.
if got := strings.Count(out, `<div class="column is-6">`); got != 2 {
func TestAccountReportAuthorizationRecord(t *testing.T) { t.Errorf("Account has %d equal columns, want 2", got)
out := renderSignedIn(t, "account", accountFixture())
pageHas(t, "Account", out,
`<h3>Report authorization</h3>`, `<span class="tag is-success is-light">published</span>`,
`*._report._dmarc.example.org`, `v=DMARC1;`, `<span class="tag is-success is-light">authorized</span>`,
)
pageLacks(t, "Account", out, `In DNS now`)
// Not published: what DNS returned is shown, with the fix.
a := accountFixture().WithAuthorization("_report._dmarc.hub.example", "v=DMARC1;", "error", "The record says something else.", []string{`"v=DMARC2"`})
out = renderSignedIn(t, "account", a)
pageHas(t, "Account", out, `In DNS now`, `&#34;v=DMARC2&#34;`, `The record says something else.`,
`<span class="tag is-danger is-light">not published</span>`, `not authorized`)
// Not found at all: nothing to compare, so the explanation takes the note's place.
a = accountFixture().WithAuthorization("_report._dmarc.hub.example", "v=DMARC1;", "warn", "No record was found.", nil)
out = renderSignedIn(t, "account", a)
pageHas(t, "Account", out, `No record was found.`)
pageLacks(t, "Account", out, `In DNS now`)
// An address that needs no authorization draws no second record.
a = accountFixture()
a.Authorization, a.DMARC.End = nil, nil
out = renderSignedIn(t, "account", a)
pageLacks(t, "Account", out, `Report authorization`)
}
func TestAccountOffersHostedOnlyWithIngest(t *testing.T) {
a := NewAccount(admin(), "global", false, "")
if out := renderSignedIn(t, "account", a); strings.Contains(out, `value="hosted"`) {
t.Error("hosted reports are offered with ingest off")
} else if !strings.Contains(out, `<option value="account">My account e-mail</option>`) {
t.Error("the account e-mail choice names an e-mail the user has not set")
} }
} }
@@ -316,9 +284,9 @@ func TestAccountResultAndRefusalAreTheFlash(t *testing.T) {
// What a person typed comes back escaped when a form is shown again. // What a person typed comes back escaped when a form is shown again.
func TestAccountEscapesTheFormValues(t *testing.T) { func TestAccountEscapesTheFormValues(t *testing.T) {
a := accountFixture() a := accountFixture()
a.Username, a.Email, a.DMARCAddress = `"><script>1</script>`, `a"onfocus="x`, `x"><i>` a.Username, a.Email = `"><script>1</script>`, `a"onfocus="x`
out := renderSignedIn(t, "account", a) out := renderSignedIn(t, "account", a)
pageLacks(t, "Account", out, `<script>1`, `"onfocus="x`, `<i>`) pageLacks(t, "Account", out, `<script>1`, `"onfocus="x`)
} }
// Event notifications are not built (roadmap: panel-notifications), and a page // Event notifications are not built (roadmap: panel-notifications), and a page
+1 -37
View File
@@ -359,9 +359,6 @@ type UserDeleteInput struct {
AllInbound bool AllInbound bool
Outbound []string Outbound []string
Inbound []string Inbound []string
// Following names the domains whose DMARC report address is this user's
// default: with the user gone they have none.
Following []string
} }
// NewUserDelete builds the page. // NewUserDelete builds the page.
@@ -386,7 +383,7 @@ func NewUserDelete(m Meta, in UserDeleteInput) *UserDelete {
} }
if in.Global { if in.Global {
p.Consequences = append(p.Consequences, Plain("remove their global access to the server and to every domain.")) p.Consequences = append(p.Consequences, Plain("remove their global access to the server and to every domain."))
return p.withFollowing(in.Following) return p
} }
var out, inb Text var out, inb Text
switch { switch {
@@ -409,39 +406,6 @@ func NewUserDelete(m Meta, in UserDeleteInput) *UserDelete {
case len(inb) > 0: case len(inb) > 0:
p.Consequences = append(p.Consequences, Rich("remove the assignments to ", inb, " (inbound).")) p.Consequences = append(p.Consequences, Rich("remove the assignments to ", inb, " (inbound)."))
} }
return p.withFollowing(in.Following)
}
// followingShown is how many domain names the DMARC consequence spells out
// before it says "and N more".
const followingShown = 3
// withFollowing adds what deleting the user does to the domains that report to
// their default address: they are left with none. Nothing is added when no
// domain follows the user.
func (p *UserDelete) withFollowing(domains []string) *UserDelete {
if len(domains) == 0 {
return p
}
shown, more := domains, 0
if len(domains) > followingShown {
shown, more = domains[:followingShown], len(domains)-followingShown
}
var list Text
for i, n := range shown {
switch {
case i == 0:
case i == len(shown)-1 && more == 0:
list = append(list, Inline{Text: " and "})
default:
list = append(list, Inline{Text: ", "})
}
list = append(list, Strong(n))
}
if more > 0 {
list = append(list, Inline{Text: " and " + strconv.Itoa(more) + " more"})
}
p.Consequences = append(p.Consequences, Rich(list, " will have no DMARC report address until someone sets one."))
return p return p
} }
+8 -24
View File
@@ -316,34 +316,18 @@ func TestEveryHelpLinkLandsOnATopic(t *testing.T) {
// change it under Account, and it can receive DMARC reports if they choose. // change it under Account, and it can receive DMARC reports if they choose.
func TestUserFormEmailHelpSaysOnlyWhatIsTrue(t *testing.T) { func TestUserFormEmailHelpSaysOnlyWhatIsTrue(t *testing.T) {
out := renderSignedIn(t, "user", userFixture()) out := renderSignedIn(t, "user", userFixture())
pageHas(t, "User", out, `The user's own address. They can change it under Account, and can choose it there as the address that receives DMARC reports.`) pageHas(t, "User", out, `The user's own address. They can change it under Account.`)
pageLacks(t, "User", out, `notification`, `reset`) pageLacks(t, "User", out, `notification`, `reset`)
} }
// With domains that follow the user's default report address, deleting them // A domain's report address belongs to the domain, not to a user: deleting a
// leaves those domains with none, and the page says so: up to three names, then // user, global or not, says nothing about any domain losing one.
// the count of the rest. With none following, the page says nothing about it. func TestUserDeleteSaysNothingOfReportAddresses(t *testing.T) {
func TestUserDeleteSaysWhichDomainsLoseTheirReportAddress(t *testing.T) { for _, in := range []UserDeleteInput{
for _, tt := range []struct { {ID: 3, Username: "acme-ops", AllOutbound: true, Outbound: []string{"example.org"}},
following []string {ID: 2, Username: "root", Global: true},
want string
}{
{[]string{"example.org"}, `<strong>example.org</strong> will have no DMARC report address until someone sets one.`},
{[]string{"example.org", "shop.example.org"}, `<strong>example.org</strong> and <strong>shop.example.org</strong> will have no DMARC report address until someone sets one.`},
{[]string{"a.example", "b.example", "c.example"}, `<strong>a.example</strong>, <strong>b.example</strong> and <strong>c.example</strong> will have no DMARC report address`},
{[]string{"a.example", "b.example", "c.example", "d.example", "e.example"}, `<strong>a.example</strong>, <strong>b.example</strong>, <strong>c.example</strong> and 2 more will have no DMARC report address`},
} { } {
in := UserDeleteInput{ID: 3, Username: "acme-ops", AllOutbound: true, Following: tt.following}
out := renderSignedIn(t, "user-delete", NewUserDelete(admin(), in)) out := renderSignedIn(t, "user-delete", NewUserDelete(admin(), in))
pageHas(t, "Delete user", out, tt.want) pageLacks(t, "Delete user", out, `report address`, `will have no`, `The domains`)
if strings.Contains(out, "d.example") && len(tt.following) > 3 {
t.Errorf("a fourth name is spelled out: %v", tt.following)
}
} }
// A global user's domains follow them as well.
out := renderSignedIn(t, "user-delete", NewUserDelete(admin(), UserDeleteInput{ID: 2, Username: "root", Global: true, Following: []string{"example.org"}}))
pageHas(t, "Delete user", out, `will have no DMARC report address`)
out = renderSignedIn(t, "user-delete", userDeleteFixture())
pageLacks(t, "Delete user", out, `DMARC report address`, `The domains`)
} }
+8 -1
View File
@@ -218,7 +218,14 @@ code { background: var(--sp-well); color: var(--sp-ink); border-radius: 3px; pad
.box > .table-container td:first-child, .box > .table-container th:first-child { padding-left: 1.25rem; } .box > .table-container td:first-child, .box > .table-container th:first-child { padding-left: 1.25rem; }
.box > .table-container td:last-child, .box > .table-container th:last-child { padding-right: 1.25rem; } .box > .table-container td:last-child, .box > .table-container th:last-child { padding-right: 1.25rem; }
.table td.sp-actions { text-align: right; white-space: nowrap; } .table td.sp-actions { text-align: right; white-space: nowrap; }
.table td.sp-actions a { margin-left: .9rem; } /* An action that changes state (New password, Delete) is a POST, so it is a form
with a button, not a link. The form sits inline like the links beside it and
the button is handed the link's colour and type; Bulma already resets the
button and shares the pointer and the focus ring between a and button, and a
link here has no hover colour of its own. */
.table td.sp-actions a, .table td.sp-actions form { margin-left: .9rem; }
.table td.sp-actions form { display: inline; }
.table td.sp-actions button { color: var(--bulma-link-text); font: inherit; }
.table td.sp-clip { max-width: 13rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .table td.sp-clip { max-width: 13rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tag { font-family: var(--sp-mono); font-weight: 600; letter-spacing: .04em; } .tag { font-family: var(--sp-mono); font-weight: 600; letter-spacing: .04em; }
.sp-empty { padding: 2.2rem 1.25rem; text-align: center; color: var(--sp-muted); } .sp-empty { padding: 2.2rem 1.25rem; text-align: center; color: var(--sp-muted); }
+20
View File
@@ -42,6 +42,26 @@
}); });
}); });
// --- Fill buttons -----------------------------------------------------
// A type="button" with data-fill="<id of an input>" and data-fill-value="<text>"
// puts the text in that input and tells the page it changed. It only fills:
// saving stays the form's own button. Without scripts the button does nothing
// and the field can be typed in.
document.addEventListener("click", function (ev) {
var button = ev.target.closest("button[data-fill]");
if (!button) {
return;
}
var input = document.getElementById(button.dataset.fill);
if (!input) {
return;
}
input.value = button.dataset.fillValue || "";
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));
input.focus();
});
// --- Confirmation on destructive forms -------------------------------- // --- Confirmation on destructive forms --------------------------------
// Forms that delete something or invalidate a working credential carry a // Forms that delete something or invalidate a working credential carry a
// data-confirm message. The prompt lives here rather than in an inline // data-confirm message. The prompt lives here rather than in an inline
+7 -23
View File
@@ -1,24 +1,26 @@
{{/* Account (mockup account.html): the signed-in user's own — name and e-mail, {{/* Account (mockup account.html): the signed-in user's own — name and e-mail,
password, and where DMARC reports about their domains go. Each form is its and password. Each form is its own POST and neither asks for the other's
own POST and none of them asks for the others' fields. Every role has this fields. Every role has this page; the instance's settings are on Server >
page; the instance's settings are on Server > Settings (settings.html). */}} Settings (settings.html). */}}
{{define "content"}} {{define "content"}}
{{template "page_head" .Head}} {{template "page_head" .Head}}
{{if .Flash}}{{template "flash" .Flash}}{{end}} {{if .Flash}}{{template "flash" .Flash}}{{end}}
<div class="columns"> <div class="columns">
<div class="column is-5"> <div class="column is-6">
{{template "box_open" .Profile}} {{template "box_open" .Profile}}
{{template "body_open"}} {{template "body_open"}}
<form class="sp-form" method="post" action="/account/profile"> <form class="sp-form" method="post" action="/account/profile">
<div class="field"><label class="label" for="username">Username</label><div class="control has-icons-left"><input class="input" id="username" name="username" value="{{.Username}}" autocomplete="username" autocapitalize="none" spellcheck="false" required><span class="icon is-left"><i class="ti ti-user"></i></span></div></div> <div class="field"><label class="label" for="username">Username</label><div class="control has-icons-left"><input class="input" id="username" name="username" value="{{.Username}}" autocomplete="username" autocapitalize="none" spellcheck="false" required><span class="icon is-left"><i class="ti ti-user"></i></span></div></div>
<div class="field"><label class="label" for="email">E-mail</label><div class="control has-icons-left"><input class="input" id="email" name="email" type="email" value="{{.Email}}" autocomplete="email" autocapitalize="none" spellcheck="false"><span class="icon is-left"><i class="ti ti-mail"></i></span></div> <div class="field"><label class="label" for="email">E-mail</label><div class="control has-icons-left"><input class="input" id="email" name="email" type="email" value="{{.Email}}" autocomplete="email" autocapitalize="none" spellcheck="false"><span class="icon is-left"><i class="ti ti-mail"></i></span></div>
<p class="help">Where the panel writes to you. It can also receive DMARC reports — your choice, under <a href="#dmarc">DMARC reports</a>.</p></div> <p class="help">Your own address. A domain's report address can be filled with it from Domain settings.</p></div>
<button type="submit" class="button is-primary">Save profile</button> <button type="submit" class="button is-primary">Save profile</button>
</form> </form>
{{template "body_close"}} {{template "body_close"}}
{{template "box_close"}} {{template "box_close"}}
</div>
<div class="column is-6">
{{template "box_open" .Password}} {{template "box_open" .Password}}
{{template "body_open"}} {{template "body_open"}}
<form class="sp-form" method="post" action="/account/password"> <form class="sp-form" method="post" action="/account/password">
@@ -33,23 +35,5 @@
{{template "body_close"}} {{template "body_close"}}
{{template "box_close"}} {{template "box_close"}}
</div> </div>
<div class="column is-7">
{{template "box_open" .DMARC}}
{{template "record_open"}}
<form class="sp-form" method="post" action="/account/dmarc">
<div class="field"><label class="label" for="dmarc_default">My default report address</label>
<div class="control"><div class="select is-fullwidth"><select id="dmarc_default" name="dmarc_default" data-custom-mode="custom">{{$sel := .DMARCSelected}}{{range .DMARCOptions}}
<option value="{{.Value}}"{{if eq .Value $sel}} selected{{end}}>{{.Label}}</option>{{end}}
</select></div></div>
<p class="help">{{template "inline" .DMARCHelp}}</p></div>
<div class="field" data-custom-address><label class="label" for="dmarc_default_address">Report address</label>
<div class="control"><input class="input" id="dmarc_default_address" name="dmarc_default_address" type="email" value="{{.DMARCAddress}}" placeholder="reports@your-mail-domain.com" autocomplete="off" autocapitalize="none" spellcheck="false"></div></div>
<button type="submit" class="button is-primary">Save report address</button>
</form>
{{template "record_close"}}
{{if .Authorization}}{{template "dns_record" .Authorization}}
{{end}}{{template "box_close"}}
</div>
</div> </div>
{{end}} {{end}}
@@ -36,6 +36,11 @@
never a link. */}} never a link. */}}
{{define "action"}}{{if .Post}}<form method="post" action="{{.Post}}"{{if .Confirm}} data-confirm="{{.Confirm}}"{{end}}><button type="submit" class="button{{if .Primary}} is-primary{{end}}{{if .Danger}} is-danger is-light{{end}}">{{if .Icon}}<i class="ti {{.Icon}}"></i>{{end}}{{.Label}}</button></form>{{else}}<a class="button{{if .Primary}} is-primary{{end}}{{if .Danger}} is-danger is-light{{end}}" href="{{.Href}}">{{if .Icon}}<i class="ti {{.Icon}}"></i>{{end}}{{.Label}}</a>{{end}}{{end}} {{define "action"}}{{if .Post}}<form method="post" action="{{.Post}}"{{if .Confirm}} data-confirm="{{.Confirm}}"{{end}}><button type="submit" class="button{{if .Primary}} is-primary{{end}}{{if .Danger}} is-danger is-light{{end}}">{{if .Icon}}<i class="ti {{.Icon}}"></i>{{end}}{{.Label}}</button></form>{{else}}<a class="button{{if .Primary}} is-primary{{end}}{{if .Danger}} is-danger is-light{{end}}" href="{{.Href}}">{{if .Icon}}<i class="ti {{.Icon}}"></i>{{end}}{{.Label}}</a>{{end}}{{end}}
{{/* row_action: a POST in the last cell of a table row (td.sp-actions), a form
with one button that is drawn like the links beside it. It sits against the
previous link with no whitespace between, as the cell's spacing is margins. */}}
{{define "row_action"}}<form method="post" action="{{.Post}}"{{if .Confirm}} data-confirm="{{.Confirm}}"{{end}}><button type="submit"{{if .Danger}} class="has-text-danger"{{end}}>{{.Label}}</button></form>{{end}}
{{/* ---- page head and postmark */}} {{/* ---- page head and postmark */}}
{{/* page_head opens every page (input: Head): the path or section as the {{/* page_head opens every page (input: Head): the path or section as the
+1 -1
View File
@@ -73,7 +73,7 @@
<div class="table-container"><table class="table is-fullwidth is-hoverable"> <div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Time, UTC</th><th>Name</th><th>sp-clip</th><th>Status</th><th></th></tr></thead> <thead><tr><th>Time, UTC</th><th>Name</th><th>sp-clip</th><th>Status</th><th></th></tr></thead>
<tbody> <tbody>
<tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:01:52</td><td><a href="#"><strong>the row's link</strong></a></td><td class="sp-clip">A long subject is clipped with an ellipsis instead of wrapping the row</td><td><span class="{{status_tag "sent"}}">sent</span></td><td class="sp-actions"><a href="#">Details</a><a class="has-text-danger" href="#">Delete</a></td></tr> <tr><td class="sp-mono sp-muted sp-nowrap sp-small">09-21 14:01:52</td><td><a href="#"><strong>the row's link</strong></a></td><td class="sp-clip">A long subject is clipped with an ellipsis instead of wrapping the row</td><td><span class="{{status_tag "sent"}}">sent</span></td><td class="sp-actions"><a href="#">Details</a>{{template "row_action" .RowAction}}</td></tr>
</tbody> </tbody>
</table></div> </table></div>
{{template "box_foot" .TablesFoot}} {{template "box_foot" .TablesFoot}}
+4 -3
View File
@@ -22,14 +22,15 @@
<div class="table-container"><table class="table is-fullwidth is-hoverable"> <div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Login</th><th>May send as</th><th>{{.Days}} days</th><th>Limits</th><th></th></tr></thead> <thead><tr><th>Login</th><th>May send as</th><th>{{.Days}} days</th><th>Limits</th><th></th></tr></thead>
<tbody>{{range .AppRows}} <tbody>{{range .AppRows}}
<tr><td class="sp-mono"><strong>{{.Login}}</strong></td><td>{{range $i, $a := .Senders}}{{if $i}}, {{end}}{{wbr_at $a}}{{end}}</td><td class="sp-muted">{{.Activity}}</td><td>{{range $i, $t := .Limits}}{{if $i}} {{end}}{{template "tag" $t}}{{end}}</td> <tr><td class="sp-mono sp-nowrap"><strong>{{.Login}}</strong></td><td>{{range $i, $a := .Senders}}{{if $i}}, {{end}}{{wbr_at $a}}{{end}}</td><td class="sp-muted">{{.Activity}}</td><td>{{range $i, $t := .Limits}}{{if $i}} {{end}}{{template "tag" $t}}{{end}}</td>
<td class="sp-actions"><a href="{{.Edit}}">Edit</a></td></tr>{{end}} <td class="sp-actions"><a href="{{.Edit}}">Edit</a>{{range .Actions}}{{template "row_action" .}}{{end}}</td></tr>{{end}}
</tbody> </tbody>
</table></div> </table></div>
{{else}} {{else}}
{{template "empty_state" .AppsEmpty}} {{template "empty_state" .AppsEmpty}}
{{end}} {{end}}
{{template "box_close"}} {{if .AppRows}}{{template "box_foot" .AppsFoot}}
{{end}}{{template "box_close"}}
{{template "box_open" .Connection}} {{template "box_open" .Connection}}
{{template "body_open"}} {{template "body_open"}}
@@ -16,8 +16,9 @@
<option value="{{.Value}}"{{if eq .Value $sel}} selected{{end}}>{{.Label}}</option>{{end}} <option value="{{.Value}}"{{if eq .Value $sel}} selected{{end}}>{{.Label}}</option>{{end}}
</select></div></div> </select></div></div>
<p class="help">{{template "inline" .RuaHelp}}</p></div> <p class="help">{{template "inline" .RuaHelp}}</p></div>
<div class="field" data-custom-address><label class="label" for="rua_email">Custom report address</label> <div class="field" data-custom-address><label class="label" for="rua_email">Report address</label>
<div class="control"><input class="input" id="rua_email" name="dmarc_rua_email" type="email" value="{{.RuaCustom}}" placeholder="reports@your-mail-domain.com" autocomplete="off" autocapitalize="none" spellcheck="false"></div></div> <div class="field has-addons"><div class="control is-expanded"><input class="input" id="rua_email" name="dmarc_rua_email" type="email" value="{{.RuaCustom}}" placeholder="reports@your-mail-domain.com" autocomplete="off" autocapitalize="none" spellcheck="false"></div>
{{if .RuaMine}}<div class="control"><button type="button" class="button" data-fill="rua_email" data-fill-value="{{.RuaMine}}"><i class="ti ti-mail"></i>Use my e-mail</button></div>{{end}}</div></div>
<button type="submit" class="button is-primary">Save report address</button> <button type="submit" class="button is-primary">Save report address</button>
</form> </form>
{{template "body_close"}} {{template "body_close"}}
+1 -1
View File
@@ -16,7 +16,7 @@
{{template "body_open"}} {{template "body_open"}}
<div class="field"><label class="label" for="username">Username</label><div class="control"><input class="input" id="username" name="username" value="{{.Username}}" autocomplete="username" autocapitalize="none" spellcheck="false" required></div></div> <div class="field"><label class="label" for="username">Username</label><div class="control"><input class="input" id="username" name="username" value="{{.Username}}" autocomplete="username" autocapitalize="none" spellcheck="false" required></div></div>
<div class="field"><label class="label" for="email">E-mail</label><div class="control"><input class="input" id="email" name="email" type="email" value="{{.Email}}" autocomplete="off"></div> <div class="field"><label class="label" for="email">E-mail</label><div class="control"><input class="input" id="email" name="email" type="email" value="{{.Email}}" autocomplete="off"></div>
<p class="help">The user's own address. They can change it under Account, and can choose it there as the address that receives DMARC reports.</p></div> <p class="help">The user's own address. They can change it under Account.</p></div>
<div class="field"><label class="label" for="password">Password</label><div class="control"><input class="input" id="password" name="password" type="password" autocomplete="new-password"{{if .NewUser}} required{{end}}></div> <div class="field"><label class="label" for="password">Password</label><div class="control"><input class="input" id="password" name="password" type="password" autocomplete="new-password"{{if .NewUser}} required{{end}}></div>
<p class="help">{{if .NewUser}}At least {{.PasswordMin}} characters.{{else}}Leave empty to keep the current one.{{end}}</p></div> <p class="help">{{if .NewUser}}At least {{.PasswordMin}} characters.{{else}}Leave empty to keep the current one.{{end}}</p></div>
<div class="field"><label class="label" for="role">Role</label> <div class="field"><label class="label" for="role">Role</label>
-1
View File
@@ -188,7 +188,6 @@ func (s *Server) muxes() (public, authed *http.ServeMux) {
authed.HandleFunc("GET /account", h.HandleAccount) authed.HandleFunc("GET /account", h.HandleAccount)
authed.HandleFunc("POST /account/profile", h.HandleAccountProfile) authed.HandleFunc("POST /account/profile", h.HandleAccountProfile)
authed.HandleFunc("POST /account/password", h.HandleAccountPassword) authed.HandleFunc("POST /account/password", h.HandleAccountPassword)
authed.HandleFunc("POST /account/dmarc", h.HandleAccountDMARC)
// Overview. Its handler checks the role itself: the page is global-only // Overview. Its handler checks the role itself: the page is global-only
// but does not live under /server/. // but does not live under /server/.