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>
@@ -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
|
||||
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.
|
||||
- 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
|
||||
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
|
||||
|
||||
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 600 KiB After Width: | Height: | Size: 598 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 145 KiB |
|
Before Width: | Height: | Size: 182 KiB After Width: | Height: | Size: 202 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 57 KiB |
@@ -64,26 +64,10 @@ func (s *Service) AllowedRecipients() ([]string, error) {
|
||||
seen[addr] = true
|
||||
out = append(out, addr)
|
||||
}
|
||||
// A user's own default that points at this host is accepted even before a
|
||||
// domain follows it, so the address can be published first. With no such
|
||||
// default anywhere, the instance's generic mailbox stays open — the address
|
||||
// the guide tells a fresh install to publish.
|
||||
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 {
|
||||
// The instance's generic mailbox is always open: it is the address the
|
||||
// guide tells a fresh install to publish. A domain's own report address
|
||||
// opens the mailbox it names, if that is on this host.
|
||||
add(DefaultHostedReportAddress(s.hostname))
|
||||
}
|
||||
domains, err := s.store.ListDomains()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -91,11 +75,7 @@ func (s *Service) AllowedRecipients() ([]string, error) {
|
||||
for _, d := range domains {
|
||||
// add keeps only addresses on this host, so a domain reporting elsewhere
|
||||
// (or nowhere) opens nothing here.
|
||||
rua, err := s.store.DomainDMARCRua(d, HostedReportAddress(s.hostname, d.Name))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
add(rua)
|
||||
add(d.DMARCRua)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ type DomainExport struct {
|
||||
Domain string `json:"domain"`
|
||||
DKIMSelector string `json:"dkim_selector"`
|
||||
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"`
|
||||
Applications []AppExport `json:"applications"`
|
||||
}
|
||||
@@ -71,14 +71,9 @@ func (s *Service) Export(id int64) (DomainExport, error) {
|
||||
Domain: d.Name,
|
||||
DKIMSelector: d.DKIMSelector,
|
||||
DKIMPrivateKey: string(pem),
|
||||
DMARCRua: d.DMARCRua,
|
||||
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)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
if exp.DMARCRua != nil {
|
||||
if err := s.store.SetDomainDMARCAddress(d.ID, *exp.DMARCRua); err != nil {
|
||||
if exp.DMARCRua != "" {
|
||||
if err := s.store.SetDomainDMARCAddress(d.ID, exp.DMARCRua); err != nil {
|
||||
s.importRollback(d.ID)
|
||||
return store.Domain{}, err
|
||||
}
|
||||
d.DMARCRua = *exp.DMARCRua
|
||||
d.DMARCRua = exp.DMARCRua
|
||||
}
|
||||
if exp.RateLimit != nil {
|
||||
if err := s.importRateLimit(store.RateLimitScopeDomain, d.ID, *exp.RateLimit); err != nil {
|
||||
|
||||
@@ -110,11 +110,11 @@ func TestExportImportRoundTrip(t *testing.T) {
|
||||
if got.Name != "example.com" || got.DKIMSelector != "selfpost" {
|
||||
t.Errorf("imported domain = %+v", got)
|
||||
}
|
||||
if got.DMARCRuaUserID.Valid || got.DMARCRua != "reports@hub.example" {
|
||||
t.Errorf("imported dmarc rua = %q (follows user: %v)", got.DMARCRua, got.DMARCRuaUserID.Valid)
|
||||
if got.DMARCRua != "reports@hub.example" {
|
||||
t.Errorf("imported dmarc rua = %q", got.DMARCRua)
|
||||
}
|
||||
if exp.DMARCRua == nil || *exp.DMARCRua != "reports@hub.example" {
|
||||
t.Errorf("exported dmarc rua = %v", exp.DMARCRua)
|
||||
if exp.DMARCRua != "reports@hub.example" {
|
||||
t.Errorf("exported dmarc rua = %q", exp.DMARCRua)
|
||||
}
|
||||
// The DKIM key was imported byte-for-byte, so the DNS record is unchanged.
|
||||
dstKey, err := dstOdk.ExportKey("example.com", "selfpost")
|
||||
|
||||
@@ -25,11 +25,11 @@ type Domain struct {
|
||||
ID int64
|
||||
Name string
|
||||
DKIMSelector string
|
||||
// Where DMARC aggregate reports go. With DMARCRuaUserID set the domain
|
||||
// follows that user's default (DMARCDefault) and DMARCRua is ignored;
|
||||
// otherwise DMARCRua is the address itself, "" meaning no reports.
|
||||
// Where DMARC aggregate reports go: the address itself, "" meaning no
|
||||
// reports (which is what a new domain has). Whether it is the hosted
|
||||
// address or one typed for the domain is told by its value; the store does
|
||||
// not know the server's hostname.
|
||||
DMARCRua string
|
||||
DMARCRuaUserID sql.NullInt64
|
||||
CreatedAt time.Time
|
||||
AppCount int
|
||||
}
|
||||
@@ -60,7 +60,7 @@ func (s *Store) AddDomain(name, selector string) (Domain, error) {
|
||||
// ordered by name.
|
||||
func (s *Store) ListDomains() ([]Domain, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.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)
|
||||
FROM domains d
|
||||
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.
|
||||
func (s *Store) ListDomainsForUser(userID int64) ([]Domain, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT d.id, d.name, d.dkim_selector, d.dmarc_rua, d.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)
|
||||
FROM domains d
|
||||
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.
|
||||
func (s *Store) GetDomain(id int64) (Domain, error) {
|
||||
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)
|
||||
FROM domains d
|
||||
WHERE d.id = ?`, id)
|
||||
@@ -154,7 +154,7 @@ func scanDomain(r scanRow) (Domain, error) {
|
||||
d Domain
|
||||
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
|
||||
}
|
||||
d.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
@@ -172,21 +172,10 @@ func isUniqueViolation(err error) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// SetDomainDMARCAddress gives the domain a report address of its own; "" means
|
||||
// no aggregate reports. The domain stops following anyone's default.
|
||||
// SetDomainDMARCAddress sets where the domain's aggregate reports go; "" means
|
||||
// no reports.
|
||||
func (s *Store) SetDomainDMARCAddress(id int64, rua string) error {
|
||||
return s.updateDomainDMARC(id, rua, sql.NullInt64{})
|
||||
}
|
||||
|
||||
// SetDomainDMARCUser makes the domain follow userID's default report address
|
||||
// (DMARCDefault). If that user is later deleted the domain falls back to no
|
||||
// reports rather than to someone else's default.
|
||||
func (s *Store) SetDomainDMARCUser(id, userID int64) error {
|
||||
return s.updateDomainDMARC(id, "", sql.NullInt64{Int64: userID, Valid: true})
|
||||
}
|
||||
|
||||
func (s *Store) updateDomainDMARC(id int64, rua string, userID sql.NullInt64) error {
|
||||
res, err := s.db.Exec("UPDATE domains SET dmarc_rua = ?, dmarc_rua_user_id = ? WHERE id = ?", rua, userID, id)
|
||||
res, err := s.db.Exec("UPDATE domains SET dmarc_rua = ? WHERE id = ?", rua, id)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// DomainDMARCRua resolves where the domain's aggregate reports go: its own
|
||||
// address, or the default of the user it follows. hosted is the SelfPost-hosted
|
||||
// mailbox for this domain, "" when report ingest is off (the store does not
|
||||
// know the server's hostname). The result is "" for a policy-only record.
|
||||
func (s *Store) DomainDMARCRua(d Domain, hosted string) (string, error) {
|
||||
if !d.DMARCRuaUserID.Valid {
|
||||
return d.DMARCRua, nil
|
||||
}
|
||||
def, err := s.GetDMARCDefault(d.DMARCRuaUserID.Int64)
|
||||
if errors.Is(err, ErrUserNotFound) {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return def.Resolve(hosted), nil
|
||||
}
|
||||
|
||||
// DomainExists reports whether a sending domain with this name is configured.
|
||||
// Used by the DMARC ingest path to refuse reports for domains this relay does
|
||||
// not send for (security.md: report XML is attacker-supplied input).
|
||||
|
||||
@@ -5,11 +5,9 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
const hostedForTest = "dmarc+example.com@mail.example.org"
|
||||
|
||||
// A domain either has a report address of its own or follows a user's default;
|
||||
// the two never mix, and a fresh domain has neither.
|
||||
func TestDomainDMARCAddressAndUser(t *testing.T) {
|
||||
// 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.
|
||||
func TestDomainDMARCAddress(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
d, err := st.AddDomain("example.com", "sel")
|
||||
if err != nil {
|
||||
@@ -19,123 +17,50 @@ func TestDomainDMARCAddressAndUser(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("GetDomain: %v", err)
|
||||
}
|
||||
if got.DMARCRua != "" || got.DMARCRuaUserID.Valid {
|
||||
t.Fatalf("a new domain = %q / follows %v, want no reports", got.DMARCRua, got.DMARCRuaUserID)
|
||||
if got.DMARCRua != "" {
|
||||
t.Fatalf("a new domain reports to %q, want no reports", got.DMARCRua)
|
||||
}
|
||||
|
||||
if err := st.SetDomainDMARCAddress(d.ID, "reports@hub.com"); err != nil {
|
||||
t.Fatalf("SetDomainDMARCAddress: %v", err)
|
||||
for _, rua := range []string{"reports@hub.com", "dmarc+example.com@mail.example.org", ""} {
|
||||
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" {
|
||||
t.Fatalf("own address resolves to %q, %v", rua, err)
|
||||
if got, _ = st.GetDomain(d.ID); got.DMARCRua != rua {
|
||||
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) {
|
||||
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
|
||||
// today — every mode — and changes with it.
|
||||
func TestDomainFollowsUserDefault(t *testing.T) {
|
||||
// Deleting a user changes no domain's report address: the address belongs to
|
||||
// the domain, not to whoever set it.
|
||||
func TestDeletingAUserLeavesEveryDomainAddressAlone(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
if err := st.CreateGlobalUser("admin", "hash"); err != nil {
|
||||
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{})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
if err := st.SetDMARCDefault(id, DMARCDefaultCustom, "dmarc@hub.example"); err != nil {
|
||||
t.Fatalf("SetDMARCDefault: %v", err)
|
||||
if err := st.UpdateUser(id, "second", "hash", "second@hub.example"); err != nil {
|
||||
t.Fatalf("UpdateUser: %v", err)
|
||||
}
|
||||
if err := st.SetDomainDMARCUser(d.ID, id); err != nil {
|
||||
t.Fatalf("SetDomainDMARCUser: %v", err)
|
||||
typed, _ := st.AddDomain("typed.example", "sel")
|
||||
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 {
|
||||
t.Fatalf("DeleteUser: %v", err)
|
||||
}
|
||||
got, err := st.GetDomain(d.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDomain: %v", err)
|
||||
if got, _ := st.GetDomain(typed.ID); got.DMARCRua != "second@hub.example" {
|
||||
t.Errorf("typed address after deleting the user = %q", got.DMARCRua)
|
||||
}
|
||||
if got.DMARCRuaUserID.Valid || got.DMARCRua != "" {
|
||||
t.Fatalf("after deleting the followed user: %q / %v, want no reports", got.DMARCRua, got.DMARCRuaUserID)
|
||||
}
|
||||
if rua, err := st.DomainDMARCRua(got, hostedForTest); err != nil || rua != "" {
|
||||
t.Fatalf("resolved %q, %v; want no reports", rua, err)
|
||||
if got, _ := st.GetDomain(none.ID); got.DMARCRua != "" {
|
||||
t.Errorf("no-reports domain after deleting the user = %q", got.DMARCRua)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,11 +24,8 @@ CREATE TABLE settings (
|
||||
-- exists. role is a reach, not a rank: 'global' sees the whole instance,
|
||||
-- 'domain' only what is assigned below.
|
||||
--
|
||||
-- email is the user's own address — what the panel writes to. The DMARC
|
||||
-- default is a separate choice that may use it: dmarc_default_mode says where
|
||||
-- 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'.
|
||||
-- email is the user's own address — what the panel writes to, and what the
|
||||
-- button beside a domain's typed report address fills it with.
|
||||
--
|
||||
-- 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
|
||||
@@ -39,9 +36,6 @@ CREATE TABLE users (
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('global', 'domain')),
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
dmarc_default_mode TEXT NOT NULL DEFAULT 'none'
|
||||
CHECK (dmarc_default_mode IN ('hosted', 'account', 'custom', 'none')),
|
||||
dmarc_default_address TEXT NOT NULL DEFAULT '',
|
||||
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
|
||||
@@ -65,17 +59,16 @@ CREATE INDEX idx_sessions_expires_at ON sessions (expires_at);
|
||||
-- Sending (outbound) domains. DKIM keys themselves live on disk under /data;
|
||||
-- this row records the selector and metadata.
|
||||
--
|
||||
-- Where the domain's DMARC aggregate reports go (rua=): with dmarc_rua_user_id
|
||||
-- set, the domain follows that user's default and dmarc_rua is ignored;
|
||||
-- otherwise dmarc_rua is the address itself, '' meaning no reports. Deleting
|
||||
-- the user leaves the domain at "none" rather than silently handing its
|
||||
-- reports to someone else.
|
||||
-- Where the domain's DMARC aggregate reports go (rua=): dmarc_rua is the
|
||||
-- address itself, '' meaning no reports — which is what a new domain has. The
|
||||
-- three choices (no reports, SelfPost hosted, an address typed for the domain)
|
||||
-- are told apart by the value: the hosted one is the address SelfPost derives
|
||||
-- for the domain and the server's hostname. No user is involved.
|
||||
CREATE TABLE domains (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
dkim_selector TEXT NOT NULL,
|
||||
dmarc_rua TEXT NOT NULL DEFAULT '',
|
||||
dmarc_rua_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
|
||||
@@ -40,9 +40,9 @@ func TestBaselineSchema(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
want := map[string]string{
|
||||
"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",
|
||||
"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",
|
||||
"applications": "id domain_id login address_mode auth_ip_restrict auth_allowed_ips created_at",
|
||||
"application_addresses": "id application_id address",
|
||||
@@ -223,33 +223,30 @@ func TestOpenRefusesANewerSchema(t *testing.T) {
|
||||
// table does when either side goes away.
|
||||
func TestBaselineConstraints(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
insert := func(role, mode string, all int) error {
|
||||
insert := func(name, role string, all int) error {
|
||||
_, err := st.db.Exec(
|
||||
"INSERT INTO users (username, password_hash, role, dmarc_default_mode, all_domains, created_at) VALUES (?, 'h', ?, ?, ?, 'now')",
|
||||
role+mode, role, mode, all)
|
||||
"INSERT INTO users (username, password_hash, role, all_domains, created_at) VALUES (?, 'h', ?, ?, 'now')",
|
||||
name, role, all)
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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")
|
||||
}
|
||||
if err := insert("global", "profile", 0); err == nil {
|
||||
t.Error("an unknown dmarc_default_mode was accepted")
|
||||
}
|
||||
if err := insert("domain", "custom", 2); err == nil {
|
||||
if err := insert("two", "domain", 2); err == nil {
|
||||
t.Error("all_domains = 2 was accepted")
|
||||
}
|
||||
|
||||
u, err := st.GetUserByUsername("domainhosted")
|
||||
u, err := st.GetUserByUsername("everything")
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -29,59 +29,15 @@ const (
|
||||
RoleDomain Role = "domain"
|
||||
)
|
||||
|
||||
// How a user's default DMARC report address is chosen (users.dmarc_default_mode).
|
||||
const (
|
||||
DMARCDefaultHosted = "hosted" // SelfPost's own mailbox for the domain
|
||||
DMARCDefaultAccount = "account" // the user's account e-mail
|
||||
DMARCDefaultCustom = "custom" // DMARCDefault.Address
|
||||
DMARCDefaultNone = "none" // no aggregate reports
|
||||
)
|
||||
|
||||
// DMARCDefault is a user's default report address: the choice a domain takes
|
||||
// over when it follows that user (Domain.DMARCRuaUserID).
|
||||
type DMARCDefault struct {
|
||||
UserID int64
|
||||
Username string
|
||||
Mode string
|
||||
Address string // for DMARCDefaultCustom
|
||||
Email string // the account e-mail, for DMARCDefaultAccount
|
||||
}
|
||||
|
||||
// Resolve returns the address the default stands for on one domain. hosted is
|
||||
// the SelfPost-hosted mailbox for that domain, "" when report ingest is off —
|
||||
// a hosted default then resolves to no reports.
|
||||
func (d DMARCDefault) Resolve(hosted string) string {
|
||||
switch d.Mode {
|
||||
case DMARCDefaultHosted:
|
||||
return hosted
|
||||
case DMARCDefaultAccount:
|
||||
return d.Email
|
||||
case DMARCDefaultCustom:
|
||||
return d.Address
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ValidDMARCDefaultMode reports whether mode is one of the stored choices.
|
||||
func ValidDMARCDefaultMode(mode string) bool {
|
||||
switch mode {
|
||||
case DMARCDefaultHosted, DMARCDefaultAccount, DMARCDefaultCustom, DMARCDefaultNone:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// User is a panel login (not an application SASL account).
|
||||
type User struct {
|
||||
ID int64
|
||||
Username string
|
||||
PasswordHash string
|
||||
Role Role
|
||||
// Email is the user's own address — what the panel writes to. DMARC may
|
||||
// use it (DMARCDefaultAccount) but it is not a DMARC field.
|
||||
// Email is the user's own address — what the panel writes to, and what the
|
||||
// button beside a domain's typed report address fills it with.
|
||||
Email string
|
||||
DMARCDefaultMode string
|
||||
DMARCDefaultAddress string
|
||||
// AllDomains / AllInboundDomains widen a domain user's list to every
|
||||
// domain of that direction, including those added later; the id lists
|
||||
// are then ignored.
|
||||
@@ -92,19 +48,14 @@ type User struct {
|
||||
InboundDomainIDs []int64
|
||||
}
|
||||
|
||||
// DMARCDefault returns the user's default report address choice.
|
||||
func (u User) DMARCDefault() DMARCDefault {
|
||||
return DMARCDefault{UserID: u.ID, Username: u.Username, Mode: u.DMARCDefaultMode, Address: u.DMARCDefaultAddress, Email: u.Email}
|
||||
}
|
||||
|
||||
const userColumns = "id, username, password_hash, role, email, dmarc_default_mode, dmarc_default_address, all_domains, all_inbound_domains, created_at"
|
||||
const userColumns = "id, username, password_hash, role, email, all_domains, all_inbound_domains, created_at"
|
||||
|
||||
func scanUser(r scanRow) (User, error) {
|
||||
var (
|
||||
u User
|
||||
createdAt string
|
||||
)
|
||||
err := r.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.Email, &u.DMARCDefaultMode, &u.DMARCDefaultAddress,
|
||||
err := r.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.Email,
|
||||
&u.AllDomains, &u.AllInboundDomains, &createdAt)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
@@ -318,47 +269,6 @@ func (s *Store) UpdateUser(id int64, username, passwordHash, email string) error
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetDMARCDefault stores the user's default report address choice. address is
|
||||
// kept only for DMARCDefaultCustom. Every domain that follows this user
|
||||
// resolves to the new choice from the next read on.
|
||||
func (s *Store) SetDMARCDefault(userID int64, mode, address string) error {
|
||||
if !ValidDMARCDefaultMode(mode) {
|
||||
return fmt.Errorf("set dmarc default: unknown mode %q", mode)
|
||||
}
|
||||
if mode != DMARCDefaultCustom {
|
||||
address = ""
|
||||
}
|
||||
res, err := s.db.Exec(
|
||||
"UPDATE users SET dmarc_default_mode = ?, dmarc_default_address = ? WHERE id = ?", mode, address, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set dmarc default: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("set dmarc default: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDMARCDefault returns a user's default report address choice, or
|
||||
// ErrUserNotFound.
|
||||
func (s *Store) GetDMARCDefault(userID int64) (DMARCDefault, error) {
|
||||
d := DMARCDefault{UserID: userID}
|
||||
err := s.db.QueryRow(
|
||||
"SELECT username, dmarc_default_mode, dmarc_default_address, email FROM users WHERE id = ?", userID,
|
||||
).Scan(&d.Username, &d.Mode, &d.Address, &d.Email)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return DMARCDefault{}, ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return DMARCDefault{}, fmt.Errorf("get dmarc default: %w", err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// SetUserRole updates a user's role.
|
||||
func (s *Store) SetUserRole(userID int64, role Role) error {
|
||||
res, err := s.db.Exec("UPDATE users SET role = ? WHERE id = ?", role, userID)
|
||||
|
||||
@@ -26,11 +26,8 @@ func TestUpdateUser(t *testing.T) {
|
||||
if got.Email != "reports@hub.example" {
|
||||
t.Fatalf("email = %q", got.Email)
|
||||
}
|
||||
// The account e-mail is the user's own: saving it does not make it anyone's
|
||||
// report address, and nothing is mirrored into the instance settings.
|
||||
if got.DMARCDefaultMode != DMARCDefaultNone {
|
||||
t.Fatalf("dmarc default mode = %q after setting the e-mail, want %q", got.DMARCDefaultMode, DMARCDefaultNone)
|
||||
}
|
||||
// The account e-mail is the user's own: nothing is mirrored into the
|
||||
// instance settings.
|
||||
if v, err := st.GetSetting("dmarc_report_email"); err == nil && v != "" {
|
||||
t.Fatalf("the e-mail leaked into the instance settings: %q", v)
|
||||
}
|
||||
|
||||
@@ -50,13 +50,6 @@ func (h *Handlers) hostedDMARCAddress(domainName string) string {
|
||||
return dmarc.HostedReportAddress(h.cfg.Hostname, domainName)
|
||||
}
|
||||
|
||||
// domainReportAddress resolves where a domain's DMARC aggregate reports go:
|
||||
// its own address, or the default of the user it follows. "" is a policy-only
|
||||
// record.
|
||||
func (h *Handlers) domainReportAddress(d store.Domain) (string, error) {
|
||||
return h.store.DomainDMARCRua(d, h.hostedDMARCAddress(d.Name))
|
||||
}
|
||||
|
||||
func domainNameSet(domains []store.Domain) map[string]bool {
|
||||
m := make(map[string]bool, len(domains))
|
||||
for _, d := range domains {
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/validate"
|
||||
"github.com/mixeme/selfpost/internal/web/view"
|
||||
@@ -15,13 +12,13 @@ import (
|
||||
)
|
||||
|
||||
// Account is the signed-in user's own half of what used to be one Settings
|
||||
// page: who they are to the panel (username, e-mail), their password, and their
|
||||
// default DMARC report address. Every role has it. Each part is its own form
|
||||
// and its own POST, so changing one never asks for the others.
|
||||
// page: who they are to the panel (username, e-mail) and their password. Every
|
||||
// role has it. Each part is its own form and its own POST, so changing one
|
||||
// never asks for the others.
|
||||
//
|
||||
// The e-mail is the user's — what the panel writes to. The DMARC default is a
|
||||
// separate choice that may use it: a domain whose report address is "this
|
||||
// user's default" resolves to whatever is chosen here (store.DMARCDefault).
|
||||
// The e-mail is the user's — what the panel writes to. A domain's DMARC report
|
||||
// address is set in the domain's settings, where a button fills it with this
|
||||
// e-mail; nothing here refers to a domain.
|
||||
|
||||
// accountForm is what the page shows back: the stored values, or the ones just
|
||||
// submitted when a form is re-rendered with an error.
|
||||
@@ -29,8 +26,6 @@ type accountForm struct {
|
||||
Err string
|
||||
Username string
|
||||
Email string
|
||||
DMARCMode string
|
||||
DMARCAddress string
|
||||
}
|
||||
|
||||
func (h *Handlers) accountUser(w http.ResponseWriter, r *http.Request) (store.User, bool) {
|
||||
@@ -49,7 +44,7 @@ func (h *Handlers) accountUser(w http.ResponseWriter, r *http.Request) (store.Us
|
||||
}
|
||||
|
||||
func formOf(u store.User) accountForm {
|
||||
return accountForm{Username: u.Username, Email: u.Email, DMARCMode: u.DMARCDefaultMode, DMARCAddress: u.DMARCDefaultAddress}
|
||||
return accountForm{Username: u.Username, Email: u.Email}
|
||||
}
|
||||
|
||||
// HandleAccount shows the Account page.
|
||||
@@ -62,56 +57,18 @@ func (h *Handlers) HandleAccount(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *Handlers) renderAccount(w http.ResponseWriter, r *http.Request, status int, u store.User, f accountForm) {
|
||||
hosted := h.dmarc != nil && h.cfg.DMARCEnabled
|
||||
page := view.NewAccount(h.shellMeta(r), string(u.Role), hosted, u.Email)
|
||||
page := view.NewAccount(h.shellMeta(r), string(u.Role))
|
||||
page.Username, page.Email = f.Username, f.Email
|
||||
page.DMARCSelected, page.DMARCAddress = f.DMARCMode, f.DMARCAddress
|
||||
page.WithResult(accountFlash(r), f.Err)
|
||||
page.WithDomainUse(h.domainsFollowing(r, u))
|
||||
|
||||
// The stored default, not the one being typed: the authorization record
|
||||
// belongs to the address domains are actually told to publish.
|
||||
def := u.DMARCDefault()
|
||||
if addr := def.Resolve(""); addr != "" && def.Mode != store.DMARCDefaultHosted {
|
||||
if hub := dnscheck.EmailDomain(addr); hub != "" {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
ra := h.dns.ReportAuth(ctx, hub)
|
||||
cancel()
|
||||
page.WithAuthorization(dnscheck.ReportAuthRecordName(hub), dnscheck.ReportAuthExample(),
|
||||
string(ra.Status), ra.Detail, ra.Records)
|
||||
}
|
||||
}
|
||||
h.view.Render(w, status, "account", page)
|
||||
}
|
||||
|
||||
// domainsFollowing counts the outbound domains the user reaches and how many of
|
||||
// them take their report address from this user's default.
|
||||
func (h *Handlers) domainsFollowing(r *http.Request, u store.User) (following, total int) {
|
||||
p, ok := h.principal(r)
|
||||
if !ok {
|
||||
return 0, 0
|
||||
}
|
||||
domains, err := h.assignedDomains(p)
|
||||
if err != nil {
|
||||
logf("panel: account: list domains: %v", err)
|
||||
return 0, 0
|
||||
}
|
||||
for _, d := range domains {
|
||||
if d.DMARCRuaUserID.Valid && d.DMARCRuaUserID.Int64 == u.ID {
|
||||
following++
|
||||
}
|
||||
}
|
||||
return following, len(domains)
|
||||
}
|
||||
|
||||
func accountFlash(r *http.Request) string {
|
||||
switch r.URL.Query().Get("done") {
|
||||
case "profile":
|
||||
return "Profile saved."
|
||||
case "password":
|
||||
return "Password changed. Any other signed-in sessions were signed out."
|
||||
case "dmarc":
|
||||
return "Default report address saved."
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -155,9 +112,6 @@ func (h *Handlers) HandleAccountProfile(w http.ResponseWriter, r *http.Request)
|
||||
fail(http.StatusInternalServerError, "Could not save the profile. Please check the logs and try again.")
|
||||
return
|
||||
}
|
||||
// The e-mail is the report address of every domain that follows this
|
||||
// user's "my account e-mail" default.
|
||||
h.resyncAfterEmailChange(u, f.Email)
|
||||
logf("panel: user %d profile updated (username: %t, e-mail: %t)", u.ID, f.Username != u.Username, f.Email != u.Email)
|
||||
http.Redirect(w, r, "/account?done=profile", http.StatusSeeOther)
|
||||
}
|
||||
@@ -215,78 +169,3 @@ func (h *Handlers) HandleAccountPassword(w http.ResponseWriter, r *http.Request)
|
||||
logf("panel: user %d changed their password", u.ID)
|
||||
http.Redirect(w, r, "/account?done=password", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// HandleAccountDMARC saves the user's default report address: SelfPost hosted,
|
||||
// the account e-mail, another address, or none.
|
||||
func (h *Handlers) HandleAccountDMARC(w http.ResponseWriter, r *http.Request) {
|
||||
u, ok := h.accountUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
f := formOf(u)
|
||||
fail := func(status int, msg string) {
|
||||
f.Err = msg
|
||||
h.renderAccount(w, r, status, u, f)
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
fail(http.StatusBadRequest, "Invalid form submission.")
|
||||
return
|
||||
}
|
||||
f.DMARCMode = strings.TrimSpace(r.PostFormValue("dmarc_default"))
|
||||
f.DMARCAddress = strings.TrimSpace(r.PostFormValue("dmarc_default_address"))
|
||||
switch f.DMARCMode {
|
||||
case store.DMARCDefaultNone:
|
||||
case store.DMARCDefaultHosted:
|
||||
if h.dmarc == nil || !h.dmarc.Enabled() {
|
||||
fail(http.StatusBadRequest, "SelfPost-hosted reports are not enabled on this server.")
|
||||
return
|
||||
}
|
||||
case store.DMARCDefaultAccount:
|
||||
if u.Email == "" {
|
||||
fail(http.StatusBadRequest, "Your account has no e-mail yet. Save one under Profile first, or choose another address.")
|
||||
return
|
||||
}
|
||||
case store.DMARCDefaultCustom:
|
||||
if f.DMARCAddress == "" {
|
||||
fail(http.StatusBadRequest, "Enter the report address or choose another option.")
|
||||
return
|
||||
}
|
||||
if err := validate.Email(f.DMARCAddress); err != nil {
|
||||
fail(http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
default:
|
||||
fail(http.StatusBadRequest, "Choose where your DMARC reports go.")
|
||||
return
|
||||
}
|
||||
if err := h.store.SetDMARCDefault(u.ID, f.DMARCMode, f.DMARCAddress); err != nil {
|
||||
logf("panel: account: set dmarc default of user %d: %v", u.ID, err)
|
||||
fail(http.StatusInternalServerError, "Could not save the report address. Please check the logs and try again.")
|
||||
return
|
||||
}
|
||||
h.resyncDMARC("default report address")
|
||||
logf("panel: user %d set their default report address to %q", u.ID, f.DMARCMode)
|
||||
http.Redirect(w, r, "/account?done=dmarc", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// resyncAfterEmailChange is what changing a user's account e-mail does for
|
||||
// DMARC: the address is the report address of every domain that follows a user
|
||||
// whose default is "my account e-mail", so the ingest allow-list is rebuilt. It
|
||||
// is the one path both the Account page and the user form take.
|
||||
func (h *Handlers) resyncAfterEmailChange(u store.User, newEmail string) {
|
||||
if newEmail != u.Email && u.DMARCDefaultMode == store.DMARCDefaultAccount {
|
||||
h.resyncDMARC("account e-mail")
|
||||
}
|
||||
}
|
||||
|
||||
// resyncDMARC rebuilds the ingest allow-list after something that changes
|
||||
// where a domain's reports go. A failure is logged, not shown: the setting is
|
||||
// saved, and the next resync picks it up.
|
||||
func (h *Handlers) resyncDMARC(why string) {
|
||||
if h.dmarc == nil || !h.dmarc.Enabled() {
|
||||
return
|
||||
}
|
||||
if err := h.dmarc.Resync(); err != nil {
|
||||
logf("panel: dmarc resync after %s change: %v", why, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,30 +12,34 @@ import (
|
||||
|
||||
// Account is every user's own page: each part is its own form and its own
|
||||
// POST, and none of them touches what another saves.
|
||||
func TestAccountPageShowsThreeForms(t *testing.T) {
|
||||
func TestAccountPageShowsTwoForms(t *testing.T) {
|
||||
h, _ := settingsServer(t)
|
||||
out := getBody(t, h.HandleAccount, "/account")
|
||||
for _, want := range []string{
|
||||
`action="/account/profile"`, `name="email"`,
|
||||
`action="/account/password"`, `name="current_password"`,
|
||||
`action="/account/dmarc"`, `name="dmarc_default"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("account page missing %q", want)
|
||||
}
|
||||
}
|
||||
for _, gone := range []string{`name="send_log_retention_days"`, `value="hosted"`} {
|
||||
// A domain's report address is not an Account setting any more.
|
||||
for _, gone := range []string{`name="send_log_retention_days"`, `/account/dmarc`, `name="dmarc_default"`, `<h2>DMARC reports</h2>`, `Report authorization`} {
|
||||
if strings.Contains(out, gone) {
|
||||
t.Errorf("account page shows %q (instance setting, or hosted reports with ingest off)", gone)
|
||||
t.Errorf("account page shows %q", gone)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The profile form saves the name and the e-mail without asking for the
|
||||
// password and without changing the password or the DMARC default.
|
||||
// password and without changing the password or any domain's report address.
|
||||
func TestAccountProfile(t *testing.T) {
|
||||
h, _ := settingsServer(t)
|
||||
before, _ := h.store.GetUser(globalPrincipal.ID)
|
||||
d, _ := h.store.AddDomain("example.com", "mail")
|
||||
if err := h.store.SetDomainDMARCAddress(d.ID, "reports@hub.example"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec := postFormAs(h.HandleAccountProfile, globalPrincipal, "/account/profile", nil,
|
||||
url.Values{"username": {"operator"}, "email": {"mix@example.org"}})
|
||||
@@ -46,8 +50,11 @@ func TestAccountProfile(t *testing.T) {
|
||||
if u.Username != "operator" || u.Email != "mix@example.org" {
|
||||
t.Errorf("profile = %q / %q", u.Username, u.Email)
|
||||
}
|
||||
if u.PasswordHash != before.PasswordHash || u.DMARCDefaultMode != store.DMARCDefaultNone {
|
||||
t.Errorf("saving the profile changed the password or the DMARC default (%q)", u.DMARCDefaultMode)
|
||||
if u.PasswordHash != before.PasswordHash {
|
||||
t.Error("saving the profile changed the password")
|
||||
}
|
||||
if got, _ := h.store.GetDomain(d.ID); got.DMARCRua != "reports@hub.example" {
|
||||
t.Errorf("saving the profile moved a domain's report address to %q", got.DMARCRua)
|
||||
}
|
||||
|
||||
for _, bad := range []url.Values{
|
||||
@@ -97,62 +104,6 @@ func TestAccountPassword(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The default report address is the user's choice between four modes; the
|
||||
// address field belongs to "another address" alone.
|
||||
func TestAccountDMARCDefault(t *testing.T) {
|
||||
h, _ := settingsServer(t)
|
||||
save := func(form url.Values) (int, store.User) {
|
||||
rec := postFormAs(h.HandleAccountDMARC, globalPrincipal, "/account/dmarc", nil, form)
|
||||
u, _ := h.store.GetUser(globalPrincipal.ID)
|
||||
return rec.Code, u
|
||||
}
|
||||
|
||||
// "My account e-mail" needs an e-mail.
|
||||
if code, u := save(url.Values{"dmarc_default": {"account"}}); code != http.StatusBadRequest || u.DMARCDefaultMode != store.DMARCDefaultNone {
|
||||
t.Errorf("account e-mail without an e-mail = %d, mode %q", code, u.DMARCDefaultMode)
|
||||
}
|
||||
// Hosted needs report ingest, which this server has off.
|
||||
if code, u := save(url.Values{"dmarc_default": {"hosted"}}); code != http.StatusBadRequest || u.DMARCDefaultMode != store.DMARCDefaultNone {
|
||||
t.Errorf("hosted with ingest off = %d, mode %q", code, u.DMARCDefaultMode)
|
||||
}
|
||||
for _, bad := range []url.Values{
|
||||
{"dmarc_default": {"custom"}},
|
||||
{"dmarc_default": {"custom"}, "dmarc_default_address": {"nope"}},
|
||||
{"dmarc_default": {"profile"}},
|
||||
{},
|
||||
} {
|
||||
if code, _ := save(bad); code != http.StatusBadRequest {
|
||||
t.Errorf("%v = %d, want 400", bad, code)
|
||||
}
|
||||
}
|
||||
|
||||
code, u := save(url.Values{"dmarc_default": {"custom"}, "dmarc_default_address": {"dmarc@hub.example"}})
|
||||
if code != http.StatusSeeOther || u.DMARCDefaultMode != store.DMARCDefaultCustom || u.DMARCDefaultAddress != "dmarc@hub.example" {
|
||||
t.Errorf("another address = %d, %q %q", code, u.DMARCDefaultMode, u.DMARCDefaultAddress)
|
||||
}
|
||||
// A domain that follows this user now resolves to it.
|
||||
d, _ := h.store.AddDomain("example.com", "mail")
|
||||
h.followCreator(d.ID, globalPrincipal.ID)
|
||||
d, _ = h.store.GetDomain(d.ID)
|
||||
if rua, err := h.domainReportAddress(d); err != nil || rua != "dmarc@hub.example" {
|
||||
t.Errorf("a following domain reports to %q, %v", rua, err)
|
||||
}
|
||||
|
||||
if rec := postFormAs(h.HandleAccountProfile, globalPrincipal, "/account/profile", nil, url.Values{"username": {"admin"}, "email": {"mix@example.org"}}); rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("save e-mail = %d", rec.Code)
|
||||
}
|
||||
code, u = save(url.Values{"dmarc_default": {"account"}, "dmarc_default_address": {"left-over@hub.example"}})
|
||||
if code != http.StatusSeeOther || u.DMARCDefaultMode != store.DMARCDefaultAccount || u.DMARCDefaultAddress != "" {
|
||||
t.Errorf("my account e-mail = %d, %q %q", code, u.DMARCDefaultMode, u.DMARCDefaultAddress)
|
||||
}
|
||||
if rua, _ := h.domainReportAddress(d); rua != "mix@example.org" {
|
||||
t.Errorf("the following domain reports to %q, want the account e-mail", rua)
|
||||
}
|
||||
if code, u = save(url.Values{"dmarc_default": {"none"}}); code != http.StatusSeeOther || u.DMARCDefaultMode != store.DMARCDefaultNone {
|
||||
t.Errorf("none = %d, %q", code, u.DMARCDefaultMode)
|
||||
}
|
||||
}
|
||||
|
||||
// A domain administrator has the same Account page; it is not a Server page.
|
||||
func TestAccountIsOpenToADomainAdministrator(t *testing.T) {
|
||||
h, _ := settingsServer(t)
|
||||
@@ -178,7 +129,7 @@ func TestAccountPageIsDrawnInTheShell(t *testing.T) {
|
||||
out := getBody(t, h.HandleAccount, "/account")
|
||||
for _, want := range []string{
|
||||
`class="navbar is-primary"`, `Signed in as admin · global`, `<h1 class="title is-3">Account</h1>`,
|
||||
`id="dmarc"`, `href="/static/panel.css"`,
|
||||
`href="/static/panel.css"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("account page missing %q", want)
|
||||
@@ -222,7 +173,6 @@ func TestAccountResultsAreTheFlash(t *testing.T) {
|
||||
for done, want := range map[string]string{
|
||||
"profile": "Profile saved.",
|
||||
"password": "Password changed.",
|
||||
"dmarc": "Default report address saved.",
|
||||
} {
|
||||
out := getBody(t, h.HandleAccount, "/account?done="+done)
|
||||
if !strings.Contains(out, `notification is-success is-light`) || !strings.Contains(out, want) {
|
||||
@@ -247,22 +197,3 @@ func TestAccountPageForADomainAdministrator(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The default address needs an authorization record when it is on another
|
||||
// domain; with "no reports" nothing is asked of DNS and no record is drawn. The
|
||||
// count of following domains is the user's own.
|
||||
func TestAccountReportAuthorizationFollowsTheStoredDefault(t *testing.T) {
|
||||
h, _ := settingsServer(t)
|
||||
d, _ := h.store.AddDomain("example.com", "mail")
|
||||
h.followCreator(d.ID, globalPrincipal.ID)
|
||||
if _, err := h.store.AddDomain("other.example", "mail"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := getBody(t, h.HandleAccount, "/account")
|
||||
if strings.Contains(out, "Report authorization") {
|
||||
t.Error("an authorization record for a default of no reports")
|
||||
}
|
||||
if !strings.Contains(out, "1 of 2 now") {
|
||||
t.Errorf("the domains following the default are not counted:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,12 +71,7 @@ func (h *Handlers) renderDomain(w http.ResponseWriter, r *http.Request, status i
|
||||
|
||||
// What DNS actually publishes for the domain today, checked against the key
|
||||
// this server signs with.
|
||||
reportEmail, err := h.domainReportAddress(d)
|
||||
if err != nil {
|
||||
logf("panel: domain %d: dmarc report address: %v", d.ID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
reportEmail := d.DMARCRua
|
||||
dns, srv := h.domainDNS(d, record, reportEmail, false)
|
||||
records := []view.Record{
|
||||
view.DKIMRecord(d.DKIMSelector, record.Name, record.Value, dnsCheck(dns.DKIM)),
|
||||
@@ -88,7 +83,7 @@ func (h *Handlers) renderDomain(w http.ResponseWriter, r *http.Request, status i
|
||||
}
|
||||
records = append(records, view.DMARCRecord(view.DMARCRecordInput{
|
||||
Host: dnscheck.DMARCRecordName(d.Name), Value: dnscheck.DMARCExample(reportEmail), Check: dnsCheck(dns.DMARC),
|
||||
Source: h.reportSource(d, reportEmail), SettingsHref: view.DomainHref(d.ID) + "/settings", ReportsHref: reports,
|
||||
Source: h.reportSource(d), SettingsHref: view.DomainHref(d.ID) + "/settings", ReportsHref: reports,
|
||||
SameDomain: reportEmail != "" && strings.EqualFold(dnscheck.EmailDomain(reportEmail), d.Name) &&
|
||||
!(h.cfg.DMARCEnabled && dmarc.IsHostedOnHostname(reportEmail, h.cfg.Hostname)),
|
||||
}))
|
||||
@@ -112,21 +107,14 @@ func dnsCheck(r dnscheck.Result) view.DNSCheck {
|
||||
return view.DNSCheck{Status: string(r.Status), Detail: r.Detail, Found: r.Records}
|
||||
}
|
||||
|
||||
// reportSource says in words where a domain's DMARC reports go: the default of
|
||||
// the user it follows, the hosted address, an address of its own, or nowhere.
|
||||
func (h *Handlers) reportSource(d store.Domain, reportEmail string) string {
|
||||
switch {
|
||||
case d.DMARCRuaUserID.Valid:
|
||||
if def, err := h.store.GetDMARCDefault(d.DMARCRuaUserID.Int64); err == nil && def.Username != "" {
|
||||
return def.Username + "'s default"
|
||||
// reportSource says in words where a domain's DMARC reports go, for the note
|
||||
// of its DMARC record: the hosted address or the one typed for the domain,
|
||||
// written out, or "no reports".
|
||||
func (h *Handlers) reportSource(d store.Domain) string {
|
||||
if d.DMARCRua == "" {
|
||||
return "no reports"
|
||||
}
|
||||
return "a 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.
|
||||
@@ -161,6 +149,7 @@ func (h *Handlers) outAppRow(d store.Domain, a store.Application, retention int)
|
||||
return view.OutAppRow{
|
||||
Login: a.Login, Senders: senders, Activity: view.FormatActivity(stats.Total, stats.PeakPerHour), Limits: limits,
|
||||
Edit: fmt.Sprintf("%s/applications/%d", view.DomainHref(d.ID), a.ID),
|
||||
Actions: view.AppRowActions(d.ID, a.ID, a.Login),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -214,76 +203,40 @@ func (h *Handlers) renderDomainSettings(w http.ResponseWriter, r *http.Request,
|
||||
rate.Computed = intOrBlank(rl.MaxMessages)
|
||||
}
|
||||
|
||||
options, selected, custom, help := h.reportAddressForm(r, d)
|
||||
options, selected, custom, mine := h.reportAddressForm(r, d)
|
||||
if sv.RuaMode != "" {
|
||||
selected, custom = sv.RuaMode, sv.RuaCustom
|
||||
}
|
||||
page := view.NewOutDomainSettings(h.shellMeta(r), d.ID, d.Name, d.AppCount, p.IsGlobal()).
|
||||
WithReportAddress(options, selected, custom, help).
|
||||
WithReportAddress(options, selected, custom, mine).
|
||||
WithExport(validate.MinSecretFilePasswordLen).
|
||||
WithRateLimit(rate).
|
||||
WithResult("", sv.Err)
|
||||
h.view.Render(w, status, "out-domain-settings", page)
|
||||
}
|
||||
|
||||
// reportAddressForm is the choices of the report-address select for a domain
|
||||
// and the signed-in user. "inherit" makes the domain follow the default of the
|
||||
// user who saves the form (HandleDomainDMARC), so what the option says depends
|
||||
// on whom the domain follows now: the user themselves, someone else, or nobody.
|
||||
// A domain that follows someone else also offers "keep" — that user's default,
|
||||
// selected — so saving the form untouched does not take the domain over.
|
||||
func (h *Handlers) reportAddressForm(r *http.Request, d store.Domain) (options []view.Option, selected, custom string, help view.Text) {
|
||||
hosted := h.hostedDMARCAddress(d.Name)
|
||||
me, _ := h.principal(r)
|
||||
mine, _ := h.store.GetDMARCDefault(me.ID)
|
||||
address := func(a string) string {
|
||||
if a == "" {
|
||||
return "no report address yet"
|
||||
// reportAddressForm is the report-address form of a domain: the three choices
|
||||
// of the select, the one the stored address stands for, the address typed for
|
||||
// the domain (only in the typed choice) and the button that fills it with the
|
||||
// signed-in user's profile e-mail ("" when the profile has none). SelfPost
|
||||
// hosted is offered only while hosted reports are enabled.
|
||||
func (h *Handlers) reportAddressForm(r *http.Request, d store.Domain) (options []view.Option, selected, custom, mine string) {
|
||||
options = append(options, view.Option{Value: ruaNone, Label: "No reports"})
|
||||
if hosted := h.hostedDMARCAddress(d.Name); hosted != "" {
|
||||
options = append(options, view.Option{Value: ruaHosted, Label: "SelfPost hosted — " + hosted})
|
||||
}
|
||||
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"),
|
||||
"; a domain follows one named user, so two people sharing a domain never pull it two ways. Changing this changes the DMARC record to publish.")
|
||||
inherit := view.Option{Value: "inherit", Label: "My default — " + myAddr}
|
||||
followsOther := d.DMARCRuaUserID.Valid && d.DMARCRuaUserID.Int64 != me.ID
|
||||
switch {
|
||||
case followsOther:
|
||||
inherit.Label = "My default instead — " + myAddr + " (you are " + me.Username + ")"
|
||||
keep := view.Option{Value: "keep", Label: "Another user's default"}
|
||||
if other, err := h.store.GetDMARCDefault(d.DMARCRuaUserID.Int64); err == nil {
|
||||
keep.Label = other.Username + "'s default — " + address(other.Resolve(hosted))
|
||||
selected = h.reportChoice(d)
|
||||
if selected == ruaCustom {
|
||||
custom = d.DMARCRua
|
||||
}
|
||||
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 p, ok := h.principal(r); ok {
|
||||
if u, err := h.store.GetUser(p.ID); err == nil {
|
||||
mine = u.Email
|
||||
}
|
||||
if hosted != "" {
|
||||
options = append(options, view.Option{Value: "hosted", Label: "SelfPost hosted (" + hosted + ")"})
|
||||
}
|
||||
// The mockup's order: whose default is followed now, hosted, "mine instead".
|
||||
if followsOther {
|
||||
options = append(options, inherit)
|
||||
}
|
||||
options = append(options, view.Option{Value: "none", Label: "No aggregate reports"}, view.Option{Value: "custom", Label: "Custom address"})
|
||||
|
||||
switch {
|
||||
case followsOther:
|
||||
selected = "keep"
|
||||
case d.DMARCRuaUserID.Valid:
|
||||
selected = "inherit"
|
||||
case d.DMARCRua == "":
|
||||
selected = "none"
|
||||
case hosted != "" && strings.EqualFold(d.DMARCRua, hosted):
|
||||
selected = "hosted"
|
||||
default:
|
||||
selected, custom = "custom", d.DMARCRua
|
||||
}
|
||||
return options, selected, custom, help
|
||||
return options, selected, custom, mine
|
||||
}
|
||||
|
||||
// formatBound writes a multiplier bound the short way: "1.5", "5".
|
||||
@@ -320,13 +273,7 @@ func (h *Handlers) HandleDomainDNSRecheck(w http.ResponseWriter, r *http.Request
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
reportEmail, err := h.domainReportAddress(d)
|
||||
if err != nil {
|
||||
logf("panel: domain %d: dmarc report address: %v", d.ID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.domainDNS(d, record, reportEmail, true)
|
||||
h.domainDNS(d, record, d.DMARCRua, true)
|
||||
http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?rechecked=1", d.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
|
||||
@@ -263,3 +263,42 @@ func TestRegeneratedPasswordIsNotCached(t *testing.T) {
|
||||
t.Error("the response does not show the new password")
|
||||
}
|
||||
}
|
||||
|
||||
// New password and Delete are in the application's row of the domain page, for
|
||||
// whoever may edit the application: each is a POST form aimed at the
|
||||
// application's own handler and asks first. A domain administrator of another
|
||||
// domain gets a 404 from the same targets.
|
||||
func TestApplicationRowCarriesNewPasswordAndDelete(t *testing.T) {
|
||||
s := newAppStand(t)
|
||||
a, _, err := s.h.apps.CreateWithSettings(s.d.ID, "prod-server", app.Settings{Mode: store.AddressModeWildcard}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
row := `<td class="sp-actions"><a href="/outbound/domains/1/applications/` + idStr(a.ID) + `">Edit</a>` +
|
||||
`<form method="post" action="/outbound/domains/1/applications/` + idStr(a.ID) + `/password" data-confirm="Generate a new password for prod-server? The current password stops working immediately."><button type="submit">New password</button></form>` +
|
||||
`<form method="post" action="/outbound/domains/1/applications/` + idStr(a.ID) + `/delete" data-confirm="Delete application prod-server? Its credentials stop working immediately."><button type="submit" class="has-text-danger">Delete</button></form></td>`
|
||||
|
||||
has(t, "domain page", pageOf(t, s.h.HandleDomainDetail, "/outbound/domains/1"), row,
|
||||
`New password and Delete ask for confirmation; the old password stops working at once.`)
|
||||
ops := domainAdmin(t, s.h.store, "ops", s.d.ID)
|
||||
has(t, "domain page for its administrator", send(s.h.HandleDomainDetail, &ops, "GET", "/outbound/domains/1", s.paths, nil).Body.String(), row)
|
||||
|
||||
// The row's targets are the application page's own handlers.
|
||||
paths := map[string]string{"id": idStr(s.d.ID), "aid": idStr(a.ID)}
|
||||
if rec := postFormAs(s.h.HandleRegenPassword, ops, "/outbound/domains/1/applications/1/password", paths, url.Values{}); rec.Code != http.StatusOK ||
|
||||
rec.Header().Get("Cache-Control") != "no-store" || !strings.Contains(rec.Body.String(), s.sasl["prod-server"]) {
|
||||
t.Errorf("the row's New password as a domain administrator = %d, Cache-Control %q", rec.Code, rec.Header().Get("Cache-Control"))
|
||||
}
|
||||
stranger := domainAdmin(t, s.h.store, "stranger")
|
||||
for name, h := range map[string]http.HandlerFunc{"password": s.h.HandleRegenPassword, "delete": s.h.HandleDeleteApplication} {
|
||||
if rec := postFormAs(h, stranger, "/outbound/domains/1/applications/1/"+name, paths, url.Values{}); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("%s as the administrator of another domain = %d, want 404", name, rec.Code)
|
||||
}
|
||||
}
|
||||
if rec := postFormAs(s.h.HandleDeleteApplication, ops, "/outbound/domains/1/applications/1/delete", paths, url.Values{}); rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("the row's Delete as a domain administrator = %d", rec.Code)
|
||||
}
|
||||
if _, err := s.h.store.GetApplicationByLogin("prod-server"); err == nil {
|
||||
t.Error("the application is still there after Delete")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,12 +273,8 @@ func (h *Handlers) HandleImportDomain(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderBackupPage(w, r, status, msg)
|
||||
return
|
||||
}
|
||||
// An export without an address of its own followed someone's default on the
|
||||
// server it came from; here it follows whoever imports it.
|
||||
if exp.DMARCRua == nil {
|
||||
if p, ok := h.principal(r); ok {
|
||||
h.followCreator(d.ID, p.ID)
|
||||
}
|
||||
if exp.DMARCRua != "" {
|
||||
h.resyncDMARC("domain import")
|
||||
}
|
||||
http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?imported=1", d.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -6,10 +6,16 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/dmarc"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/validate"
|
||||
"github.com/mixeme/selfpost/internal/web/view"
|
||||
)
|
||||
|
||||
// HandleDomainDMARC saves per-domain DMARC rua= settings.
|
||||
// HandleDomainDMARC saves where a domain's aggregate reports (rua=) go. The
|
||||
// form offers exactly three choices: "none", "hosted" (the address SelfPost
|
||||
// derives for the domain, offered only while hosted reports are enabled) and
|
||||
// "custom" (an address typed for the domain). What is stored is the address
|
||||
// alone; "" is no reports.
|
||||
func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", "POST")
|
||||
@@ -25,9 +31,6 @@ func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// "inherit" makes the domain follow the default of the user who chose it;
|
||||
// every other mode gives the domain an address of its own ("" = none).
|
||||
follow := false
|
||||
rua := ""
|
||||
mode := strings.TrimSpace(r.PostFormValue("dmarc_rua_mode"))
|
||||
// A refusal shows the form again with the choice that was made.
|
||||
@@ -35,30 +38,19 @@ func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderDomainSettings(w, r, status, d, settingsView{Err: msg, RuaMode: mode, RuaCustom: strings.TrimSpace(r.PostFormValue("dmarc_rua_email"))})
|
||||
}
|
||||
switch mode {
|
||||
case "keep":
|
||||
// The domain follows another user's default and the form left it so.
|
||||
// Anything else under this value is a stale or forged form.
|
||||
if p, ok := h.principal(r); !ok || !d.DMARCRuaUserID.Valid || d.DMARCRuaUserID.Int64 == p.ID {
|
||||
refuse(http.StatusBadRequest, "Choose how aggregate reports are addressed for this domain.")
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?dmarc=1", d.ID), http.StatusSeeOther)
|
||||
return
|
||||
case "inherit":
|
||||
follow = true
|
||||
case "none":
|
||||
case "custom":
|
||||
case ruaNone:
|
||||
case ruaCustom:
|
||||
email := strings.TrimSpace(r.PostFormValue("dmarc_rua_email"))
|
||||
if err := validate.Email(email); err != nil {
|
||||
refuse(http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if email == "" {
|
||||
refuse(http.StatusBadRequest, "Enter a custom report address or choose another mode.")
|
||||
refuse(http.StatusBadRequest, "Enter the report address or choose another option.")
|
||||
return
|
||||
}
|
||||
rua = email
|
||||
case "hosted":
|
||||
case ruaHosted:
|
||||
if h.dmarc == nil || !h.dmarc.Enabled() {
|
||||
refuse(http.StatusBadRequest, "SelfPost-hosted reports are not enabled on this server.")
|
||||
return
|
||||
@@ -69,22 +61,46 @@ func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var err error
|
||||
if p, ok := h.principal(r); follow && ok {
|
||||
err = h.store.SetDomainDMARCUser(d.ID, p.ID)
|
||||
} else {
|
||||
err = h.store.SetDomainDMARCAddress(d.ID, rua)
|
||||
}
|
||||
if err != nil {
|
||||
if err := h.store.SetDomainDMARCAddress(d.ID, rua); err != nil {
|
||||
logf("panel: domain %d: save dmarc rua: %v", d.ID, err)
|
||||
refuse(http.StatusInternalServerError, "Could not save DMARC settings. Please check the logs and try again.")
|
||||
return
|
||||
}
|
||||
if h.dmarc != nil && h.dmarc.Enabled() {
|
||||
if err := h.dmarc.Resync(); err != nil {
|
||||
logf("panel: domain %d: dmarc resync: %v", d.ID, err)
|
||||
}
|
||||
}
|
||||
h.resyncDMARC("domain report address")
|
||||
h.dns.Forget(d.Name)
|
||||
http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?dmarc=1", d.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// The three choices of a domain's report address, the values of the select.
|
||||
const (
|
||||
ruaNone = view.ReportNone
|
||||
ruaHosted = view.ReportHosted
|
||||
ruaCustom = view.ReportCustom
|
||||
)
|
||||
|
||||
// reportChoice says which of the three choices a domain's stored address is:
|
||||
// none for "", hosted for the address SelfPost derives for the domain (only
|
||||
// while hosted reports are enabled — otherwise it is just an address), custom
|
||||
// for anything else.
|
||||
func (h *Handlers) reportChoice(d store.Domain) string {
|
||||
hosted := h.hostedDMARCAddress(d.Name)
|
||||
switch {
|
||||
case d.DMARCRua == "":
|
||||
return ruaNone
|
||||
case hosted != "" && strings.EqualFold(d.DMARCRua, hosted):
|
||||
return ruaHosted
|
||||
}
|
||||
return ruaCustom
|
||||
}
|
||||
|
||||
// resyncDMARC rebuilds the ingest allow-list after something that changes
|
||||
// where a domain's reports go. A failure is logged, not shown: the setting is
|
||||
// saved, and the next resync picks it up.
|
||||
func (h *Handlers) resyncDMARC(why string) {
|
||||
if h.dmarc == nil || !h.dmarc.Enabled() {
|
||||
return
|
||||
}
|
||||
if err := h.dmarc.Resync(); err != nil {
|
||||
logf("panel: dmarc resync after %s change: %v", why, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,13 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/dmarc"
|
||||
"github.com/mixeme/selfpost/internal/domain"
|
||||
"github.com/mixeme/selfpost/internal/postfix"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/auth"
|
||||
)
|
||||
@@ -24,78 +27,269 @@ func postFormAs(h http.HandlerFunc, p auth.Principal, target string, pathValues
|
||||
return rec
|
||||
}
|
||||
|
||||
// The domain form's "Same as Settings" is, on the 2.0 schema, a domain that
|
||||
// follows the default of the user who chose it; every other choice gives the
|
||||
// domain an address of its own and stops it following anyone.
|
||||
func TestDomainDMARCFollowsTheUserWhoChoseInherit(t *testing.T) {
|
||||
h, _ := settingsServer(t)
|
||||
h.domains = domain.NewService(h.store, nil, nil, "selfpost")
|
||||
d, err := h.store.AddDomain("example.com", "selfpost")
|
||||
if err != nil {
|
||||
t.Fatalf("AddDomain: %v", err)
|
||||
// dmarcRecordingMaps stands in for Postfix: the allow-lists a resync would write.
|
||||
type dmarcRecordingMaps struct{ got []postfix.DMARCMapsConfig }
|
||||
|
||||
func (m *dmarcRecordingMaps) RebuildDMARCMaps(cfg postfix.DMARCMapsConfig) error {
|
||||
m.got = append(m.got, cfg)
|
||||
return nil
|
||||
}
|
||||
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 {
|
||||
|
||||
// hostedStand is an app stand whose server receives hosted DMARC reports.
|
||||
func hostedStand(t *testing.T) (appStand, *dmarcRecordingMaps) {
|
||||
t.Helper()
|
||||
rec := postFormAs(h.HandleDomainDMARC, p, "/outbound/domains/1/settings/reports", map[string]string{"id": "1"}, values)
|
||||
s := newAppStand(t)
|
||||
maps := &dmarcRecordingMaps{}
|
||||
s.h.cfg.DMARCEnabled, s.h.cfg.Hostname = true, "mail.example.org"
|
||||
s.h.dmarc = dmarc.NewService(s.h.store, maps, "mail.example.org", true)
|
||||
return s, maps
|
||||
}
|
||||
|
||||
const hostedForExample = "dmarc-reports+example.org@mail.example.org"
|
||||
|
||||
// hostedEscaped is that address as html/template writes it into a page.
|
||||
const hostedEscaped = "dmarc-reports+example.org@mail.example.org"
|
||||
|
||||
// saveReports posts the report-address form of domain 1 as the global
|
||||
// administrator.
|
||||
func (s appStand) saveReports(form url.Values) *httptest.ResponseRecorder {
|
||||
return postFormAs(s.h.HandleDomainDMARC, globalPrincipal, "/outbound/domains/1/settings/reports", s.paths, form)
|
||||
}
|
||||
|
||||
func (s appStand) reportAddress(t *testing.T) string {
|
||||
t.Helper()
|
||||
d, err := s.h.store.GetDomain(s.d.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return d.DMARCRua
|
||||
}
|
||||
|
||||
func contains(list []string, want string) bool {
|
||||
for _, s := range list {
|
||||
if s == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// A new domain has no report address: its settings show "No reports" selected,
|
||||
// and its DMARC record says so.
|
||||
func TestNewDomainHasNoReports(t *testing.T) {
|
||||
s, _ := hostedStand(t)
|
||||
// A second OpenDKIM that reloads nothing: the stand's has no supervisor.
|
||||
odk := domain.NewOpenDKIM(t.TempDir())
|
||||
odk.SetReloadHook(func() error { return nil })
|
||||
if _, err := odk.EnsureKey("example.org", "mail"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.h.domains = domain.NewService(s.h.store, odk, s.h.apps, "mail")
|
||||
rec := postFormAs(s.h.HandleAddDomain, globalPrincipal, "/outbound/domains", nil, url.Values{"name": {"new.example"}})
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("POST %v = %d, want 303:\n%s", values, rec.Code, rec.Body.String())
|
||||
t.Fatalf("adding a domain = %d:\n%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
got, err := h.store.GetDomain(d.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDomain: %v", err)
|
||||
domains, _ := s.h.store.ListDomains()
|
||||
var added store.Domain
|
||||
for _, d := range domains {
|
||||
if d.Name == "new.example" {
|
||||
added = d
|
||||
}
|
||||
return got
|
||||
}
|
||||
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>`)
|
||||
}
|
||||
|
||||
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)
|
||||
// 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")
|
||||
}
|
||||
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)
|
||||
// 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)
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
got = save(url.Values{"dmarc_rua_mode": {"none"}})
|
||||
if got.DMARCRuaUserID.Valid || got.DMARCRua != "" {
|
||||
t.Fatalf("none: %q / follows %v", got.DMARCRua, got.DMARCRuaUserID)
|
||||
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 new domain takes its report address from whoever created it.
|
||||
func TestFollowCreator(t *testing.T) {
|
||||
h, _ := settingsServer(t)
|
||||
d, err := h.store.AddDomain("example.com", "selfpost")
|
||||
if err != nil {
|
||||
t.Fatalf("AddDomain: %v", err)
|
||||
// A refused address comes back in the field, escaped, with its choice.
|
||||
rec := s.saveReports(url.Values{"dmarc_rua_mode": {"custom"}, "dmarc_rua_email": {`x"><i>@no`}})
|
||||
has(t, "refusal", rec.Body.String(), `<option value="custom" selected>`, `value="x"><i>@no"`)
|
||||
lacks(t, "refusal", rec.Body.String(), `<i>@no`)
|
||||
}
|
||||
h.followCreator(d.ID, globalPrincipal.ID)
|
||||
got, err := h.store.GetDomain(d.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDomain: %v", err)
|
||||
|
||||
// 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)
|
||||
}
|
||||
if !got.DMARCRuaUserID.Valid || got.DMARCRuaUserID.Int64 != globalPrincipal.ID {
|
||||
t.Fatalf("the domain follows %v, want its creator (user %d)", got.DMARCRuaUserID, globalPrincipal.ID)
|
||||
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])
|
||||
}
|
||||
// The creator's default is "none" until they choose one, so the new
|
||||
// domain publishes a policy-only record — as a fresh 1.x install did.
|
||||
if rua, err := h.domainReportAddress(got); err != nil || rua != "" {
|
||||
t.Fatalf("resolved %q, %v; want no report address yet", rua, err)
|
||||
if m := typed.FindStringSubmatch(page); m != nil {
|
||||
form.Set("dmarc_rua_email", m[1])
|
||||
}
|
||||
if form.Get("dmarc_rua_mode") == "" {
|
||||
t.Fatalf("%s: no choice is selected on the page:\n%s", name, page)
|
||||
}
|
||||
if rec := s.saveReports(form); rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("%s: saving untouched %v = %d:\n%s", name, form, rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := s.reportAddress(t); got != stored {
|
||||
t.Errorf("%s: an untouched form changed %q to %q", name, stored, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// settingsAs is the settings page of domain 1 as the given principal.
|
||||
func settingsAs(t *testing.T, s appStand, p auth.Principal) string {
|
||||
t.Helper()
|
||||
rec := send(s.h.HandleDomainSettings, &p, "GET", "/outbound/domains/1/settings", s.paths, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("settings as %s = %d", p.Username, rec.Code)
|
||||
}
|
||||
return rec.Body.String()
|
||||
}
|
||||
|
||||
// The button that fills the typed address carries the signed-in user's profile
|
||||
// e-mail, escaped; without an e-mail there is no button.
|
||||
func TestFillButtonCarriesTheProfileEmail(t *testing.T) {
|
||||
s := newAppStand(t)
|
||||
lacks(t, "settings", settingsAs(t, s, globalPrincipal), `data-fill`, `Use my e-mail`)
|
||||
|
||||
admin, _ := s.h.store.GetUser(globalPrincipal.ID)
|
||||
if err := s.h.store.UpdateUser(admin.ID, admin.Username, admin.PasswordHash, `o'brien&co@example.org`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := settingsAs(t, s, globalPrincipal)
|
||||
has(t, "settings", body, `data-fill="rua_email" data-fill-value="o'brien&co@example.org"`, `Use my e-mail`)
|
||||
lacks(t, "settings", body, `o'brien`)
|
||||
// It fills; the form's own button saves, so the fill button is not a submit.
|
||||
has(t, "settings", body, `<button type="button" class="button" data-fill=`)
|
||||
|
||||
// A domain administrator is offered their own e-mail, never the global one's.
|
||||
ops := domainAdmin(t, s.h.store, "ops", s.d.ID)
|
||||
if got := settingsAs(t, s, ops); strings.Contains(got, "brien") || strings.Contains(got, "data-fill") {
|
||||
t.Errorf("the co-administrator without an e-mail is offered someone else's:\n%s", got)
|
||||
}
|
||||
opsUser, _ := s.h.store.GetUser(ops.ID)
|
||||
if err := s.h.store.UpdateUser(opsUser.ID, opsUser.Username, opsUser.PasswordHash, "ops@example.org"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
has(t, "settings for ops", settingsAs(t, s, ops), `data-fill-value="ops@example.org"`)
|
||||
}
|
||||
|
||||
// Deleting a user leaves every domain's address as it was, and a domain
|
||||
// administrator never sees another user's name or address in this form.
|
||||
func TestDeletingAUserLeavesDomainAddressesAndNamesNobody(t *testing.T) {
|
||||
s, _ := hostedStand(t)
|
||||
admin, _ := s.h.store.GetUser(globalPrincipal.ID)
|
||||
if err := s.h.store.UpdateUser(admin.ID, admin.Username, admin.PasswordHash, "boss-private@example.org"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec := s.saveReports(url.Values{"dmarc_rua_mode": {"custom"}, "dmarc_rua_email": {"reports@hub.example"}}); rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("save = %d", rec.Code)
|
||||
}
|
||||
ops := domainAdmin(t, s.h.store, "ops", s.d.ID)
|
||||
for name, body := range map[string]string{
|
||||
"settings": settingsAs(t, s, ops),
|
||||
"domain": send(s.h.HandleDomainDetail, &ops, "GET", "/outbound/domains/1", s.paths, nil).Body.String(),
|
||||
} {
|
||||
lacks(t, name+" for the co-administrator", body, `boss-private@example.org`, `admin's`, `s default`, `Another user`)
|
||||
}
|
||||
|
||||
rec := send(s.h.HandleUserDelete, &globalPrincipal, "POST", "/server/users/"+idStr(ops.ID)+"/delete", map[string]string{"uid": idStr(ops.ID)}, url.Values{})
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("deleting a user = %d:\n%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := s.reportAddress(t); got != "reports@hub.example" {
|
||||
t.Errorf("deleting a user changed the domain's address to %q", got)
|
||||
}
|
||||
confirm := send(s.h.HandleUserDeleteConfirm, &globalPrincipal, "GET", "/server/users/"+idStr(admin.ID)+"/delete", map[string]string{"uid": idStr(admin.ID)}, nil).Body.String()
|
||||
lacks(t, "delete confirmation", confirm, `report address`, `will have no`)
|
||||
}
|
||||
|
||||
@@ -68,12 +68,7 @@ func (h *Handlers) outDomainRows(domains []store.Domain, retention int, canDelet
|
||||
logf("panel: domains: domain %d: dkim record: %v", d.ID, err)
|
||||
return
|
||||
}
|
||||
reportEmail, err := h.domainReportAddress(d)
|
||||
if err != nil {
|
||||
logf("panel: domains: domain %d: dmarc report address: %v", d.ID, err)
|
||||
return
|
||||
}
|
||||
dns, _ := h.domainDNS(d, record, reportEmail, false)
|
||||
dns, _ := h.domainDNS(d, record, d.DMARCRua, false)
|
||||
rows[i].DNS = dnsTags(dns.DKIM.Status, dns.SPF.Status, dns.DMARC.Status)
|
||||
}()
|
||||
}
|
||||
@@ -92,7 +87,7 @@ func dashboardFlash(r *http.Request) string {
|
||||
// OpenDKIM reload), and redirects to the domain's page so the DNS record to
|
||||
// publish is shown (product.md).
|
||||
func (h *Handlers) HandleAddDomain(w http.ResponseWriter, r *http.Request) {
|
||||
p, ok := h.requireGlobal(w, r)
|
||||
_, ok := h.requireGlobal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -118,26 +113,9 @@ func (h *Handlers) HandleAddDomain(w http.ResponseWriter, r *http.Request) {
|
||||
"Could not add the domain. Please check the logs and try again.", raw)
|
||||
return
|
||||
}
|
||||
h.followCreator(d.ID, p.ID)
|
||||
http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d", d.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// followCreator makes a new domain take its DMARC report address from the
|
||||
// default of whoever created it (plan § Account e-mail). The domain exists
|
||||
// either way: a failure here leaves it with no report address, which its page
|
||||
// shows and its DMARC form can change.
|
||||
func (h *Handlers) followCreator(domainID, userID int64) {
|
||||
if err := h.store.SetDomainDMARCUser(domainID, userID); err != nil {
|
||||
logf("panel: domain %d: follow user %d's dmarc default: %v", domainID, userID, err)
|
||||
return
|
||||
}
|
||||
if h.dmarc != nil && h.dmarc.Enabled() {
|
||||
if err := h.dmarc.Resync(); err != nil {
|
||||
logf("panel: domain %d: dmarc resync: %v", domainID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HandleDeleteConfirm shows the cascade warning before a domain is removed.
|
||||
func (h *Handlers) HandleDeleteConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requireGlobal(w, r); !ok {
|
||||
|
||||
@@ -95,7 +95,7 @@ func TestDomainPageShowsRecordsApplicationsAndConnection(t *testing.T) {
|
||||
has(t, "domain", body,
|
||||
`<h1 class="title is-3">example.org</h1>`, `class="sp-postmark`, `<h3>DKIM</h3>`, `<h3>SPF</h3>`, `<h3>DMARC</h3>`,
|
||||
`mail._domainkey.example.org`, `v=DKIM1`, `_dmarc.example.org`, `v=DMARC1; p=none`, `v=spf1 `,
|
||||
`<td class="sp-mono"><strong>prod-server</strong></td>`, `alerts@<wbr>example.org`, `200 / h`, `2 IPs`,
|
||||
`<td class="sp-mono sp-nowrap"><strong>prod-server</strong></td>`, `alerts@<wbr>example.org`, `200 / h`, `2 IPs`,
|
||||
`href="/outbound/domains/1/applications/`+idStr(a.ID)+`">Edit</a>`,
|
||||
`mail.example.org`, `STARTTLS (submission)`, `action="/outbound/domains/1/dns-recheck"`,
|
||||
`href="/outbound/log?domain=example.org"`, `href="/outbound/domains/1/settings#reports"`, `href="/outbound/domains/1/delete"`)
|
||||
@@ -120,60 +120,16 @@ func TestDomainPageWithoutApplicationsSaysSo(t *testing.T) {
|
||||
lacks(t, "domain", body, `<th>Login</th>`)
|
||||
}
|
||||
|
||||
// The report address form offers what the viewer can choose, and says whose
|
||||
// default the domain follows.
|
||||
func TestDomainSettingsReportAddressChoices(t *testing.T) {
|
||||
// The rest of the settings page, beside the report address (the three choices
|
||||
// are in handlers_dmarc_test.go).
|
||||
func TestDomainSettingsPageHasItsBoxes(t *testing.T) {
|
||||
s := newAppStand(t)
|
||||
body := pageOf(t, s.h.HandleDomainSettings, "/outbound/domains/1/settings")
|
||||
has(t, "settings", body, `<h1 class="title is-3">Domain settings</h1>`,
|
||||
`action="/outbound/domains/1/settings/reports"`, `<option value="inherit">My default — no report address yet</option>`,
|
||||
`<option value="none" selected>`, `<option value="custom">`,
|
||||
`action="/outbound/domains/1/settings/reports"`, `<option value="none" selected>No reports</option>`, `<option value="custom">A specific address</option>`,
|
||||
`action="/outbound/domains/1/settings/ratelimit"`, `action="/outbound/domains/1/settings/export"`,
|
||||
`name="encrypt" value="1" checked`, `minlength="12"`, `<h2>Delete domain</h2>`, `Removes the DKIM key.`)
|
||||
lacks(t, "settings", body, `value="hosted"`, `name="clear"`, `ratelimit/recalc`)
|
||||
|
||||
// Following the signed-in user's default.
|
||||
if err := s.h.store.SetDomainDMARCUser(s.d.ID, globalPrincipal.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.h.store.SetDMARCDefault(globalPrincipal.ID, store.DMARCDefaultCustom, "dmarc@hub.example"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body = pageOf(t, s.h.HandleDomainSettings, "/outbound/domains/1/settings")
|
||||
has(t, "settings", body, `<option value="inherit" selected>admin's default — dmarc@hub.example</option>`)
|
||||
|
||||
// "keep" names nobody when the domain follows the person saving: refused.
|
||||
if rec := postFormAs(s.h.HandleDomainDMARC, globalPrincipal, "/outbound/domains/1/settings/reports", map[string]string{"id": "1"},
|
||||
url.Values{"dmarc_rua_mode": {"keep"}}); rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("keep on a domain that follows the saver = %d, want 400", rec.Code)
|
||||
}
|
||||
|
||||
// Following someone else's: theirs is selected and is what an untouched form
|
||||
// saves; one's own is offered as the change.
|
||||
other := domainAdmin(t, s.h.store, "ops", s.d.ID)
|
||||
if err := s.h.store.SetDMARCDefault(other.ID, store.DMARCDefaultCustom, "ops@hub.example"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.h.store.SetDomainDMARCUser(s.d.ID, other.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body = pageOf(t, s.h.HandleDomainSettings, "/outbound/domains/1/settings")
|
||||
has(t, "settings", body, `<option value="keep" selected>ops's default — ops@hub.example</option>`,
|
||||
`<option value="inherit">My default instead — dmarc@hub.example (you are admin)</option>`)
|
||||
if rec := postFormAs(s.h.HandleDomainDMARC, globalPrincipal, "/outbound/domains/1/settings/reports", map[string]string{"id": "1"},
|
||||
url.Values{"dmarc_rua_mode": {"keep"}}); rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("saving the form untouched = %d, want 303:\n%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got, err := s.h.store.GetDomain(s.d.ID); err != nil || !got.DMARCRuaUserID.Valid || got.DMARCRuaUserID.Int64 != other.ID {
|
||||
t.Fatalf("an untouched form took the domain over: follows %v, %v; want user %d", got.DMARCRuaUserID, err, other.ID)
|
||||
}
|
||||
|
||||
// An address of its own shows in the field.
|
||||
if err := s.h.store.SetDomainDMARCAddress(s.d.ID, "reports@hub.example"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body = pageOf(t, s.h.HandleDomainSettings, "/outbound/domains/1/settings")
|
||||
has(t, "settings", body, `<option value="custom" selected>`, `value="reports@hub.example"`)
|
||||
}
|
||||
|
||||
func TestDomainSettingsShowsTheRateLimit(t *testing.T) {
|
||||
@@ -216,7 +172,7 @@ func TestDomainSettingsRefusalsStayOnTheSettingsPage(t *testing.T) {
|
||||
lacks(t, "report address refusal", body, `<b>`)
|
||||
|
||||
code, body = post(s.h.HandleDomainDMARC, "reports", url.Values{"dmarc_rua_mode": {"custom"}})
|
||||
has(t, "report address refusal", body, `Enter a custom report address or choose another mode.`)
|
||||
has(t, "report address refusal", body, `Enter the report address or choose another option.`)
|
||||
|
||||
code, body = post(s.h.HandleDomainDMARC, "reports", url.Values{"dmarc_rua_mode": {"hosted"}})
|
||||
if code != http.StatusBadRequest {
|
||||
|
||||
@@ -6,10 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/dmarc"
|
||||
"github.com/mixeme/selfpost/internal/postfix"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"github.com/mixeme/selfpost/internal/web/auth"
|
||||
)
|
||||
|
||||
// The user form sets the account e-mail: create stores it, an edit changes it,
|
||||
@@ -100,92 +97,3 @@ func TestUserFormRefusesABadEmailAndKeepsWhatWasTyped(t *testing.T) {
|
||||
t.Errorf("a bad address on create = %d, user created: %v", rec.Code, fresh.ID != 0)
|
||||
}
|
||||
}
|
||||
|
||||
type countingMaps struct{ n int }
|
||||
|
||||
func (c *countingMaps) RebuildDMARCMaps(postfix.DMARCMapsConfig) error { c.n++; return nil }
|
||||
|
||||
// Changing the e-mail of a user whose default report address is "my account
|
||||
// e-mail" rebuilds the ingest allow-list, exactly as the Account page does; for
|
||||
// anyone else, or when the address is not changed, nothing is rebuilt.
|
||||
func TestUserFormEmailChangeResyncsDMARCOnlyForTheAccountDefault(t *testing.T) {
|
||||
h, st := inboundHandlers(t)
|
||||
maps := &countingMaps{}
|
||||
h.dmarc = dmarc.NewService(st, maps, "mail.example.org", true)
|
||||
root := userWith(t, st, "root", store.RoleGlobal, store.Reach{})
|
||||
follower := userWith(t, st, "follower", store.RoleGlobal, store.Reach{})
|
||||
other := userWith(t, st, "other", store.RoleGlobal, store.Reach{})
|
||||
if err := st.SetDMARCDefault(follower.ID, store.DMARCDefaultAccount, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
save := func(who auth.Principal, email string) {
|
||||
t.Helper()
|
||||
rec := send(h.HandleUserEdit, &root, "POST", "/server/users/"+idStr(who.ID), map[string]string{"uid": idStr(who.ID)},
|
||||
url.Values{"username": {who.Username}, "email": {email}, "role": {"global"}})
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("save %s = %d:\n%s", who.Username, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
save(other, "other@example.org")
|
||||
if maps.n != 0 {
|
||||
t.Errorf("a user whose default is not the account e-mail rebuilt the maps %d time(s)", maps.n)
|
||||
}
|
||||
save(follower, "follower@example.org")
|
||||
if maps.n != 1 {
|
||||
t.Errorf("changing the account e-mail of a follower rebuilt the maps %d time(s), want 1", maps.n)
|
||||
}
|
||||
save(follower, "follower@example.org")
|
||||
if maps.n != 1 {
|
||||
t.Errorf("saving the same address again rebuilt the maps (%d)", maps.n)
|
||||
}
|
||||
}
|
||||
|
||||
// Deleting a user leaves every domain that follows their default report address
|
||||
// with none; the confirmation says which, and says nothing of the kind when no
|
||||
// domain follows.
|
||||
func TestUserDeleteConfirmationNamesTheDomainsThatLoseTheirReportAddress(t *testing.T) {
|
||||
h, st := inboundHandlers(t)
|
||||
root := userWith(t, st, "root", store.RoleGlobal, store.Reach{})
|
||||
u := userWith(t, st, "shop", store.RoleGlobal, store.Reach{})
|
||||
path, pv := "/server/users/"+idStr(u.ID)+"/delete", map[string]string{"uid": idStr(u.ID)}
|
||||
page := func() string { return send(h.HandleUserDeleteConfirm, &root, "GET", path, pv, nil).Body.String() }
|
||||
|
||||
if body := page(); strings.Contains(body, "DMARC report address") {
|
||||
t.Errorf("no domain follows the user, yet the page speaks of DMARC:\n%s", body)
|
||||
}
|
||||
|
||||
var follows []store.Domain
|
||||
for _, name := range []string{"a.example.com", "b.example.com", "c.example.com", "d.example.com"} {
|
||||
d, _ := st.AddDomain(name, "mail")
|
||||
if err := st.SetDomainDMARCUser(d.ID, u.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
follows = append(follows, d)
|
||||
}
|
||||
elsewhere, _ := st.AddDomain("z.example.com", "mail")
|
||||
if err := st.SetDomainDMARCUser(elsewhere.ID, root.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
body := page()
|
||||
want := `<strong>a.example.com</strong>, <strong>b.example.com</strong>, <strong>c.example.com</strong> and 1 more will have no DMARC report address until someone sets one.`
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("the delete page is missing %q:\n%s", want, body)
|
||||
}
|
||||
if strings.Contains(body, "z.example.com") || strings.Contains(body, "d.example.com") {
|
||||
t.Error("the page names a domain that does not follow this user, or spells out a fourth name")
|
||||
}
|
||||
|
||||
// What it says is what happens: the follower domains end up with no address.
|
||||
if rec := send(h.HandleUserDelete, &root, "POST", path, pv, url.Values{}); rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("delete = %d", rec.Code)
|
||||
}
|
||||
for _, d := range follows {
|
||||
got, _ := st.GetDomain(d.ID)
|
||||
rua, err := st.DomainDMARCRua(got, "")
|
||||
if err != nil || rua != "" {
|
||||
t.Errorf("%s still reports to %q (%v) after its user was deleted", d.Name, rua, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,7 +188,7 @@ func TestUserFormSavedUntouchedChangesNothing(t *testing.T) {
|
||||
}
|
||||
after, _ := st.GetUser(p.ID)
|
||||
if after.Username != before.Username || after.PasswordHash != before.PasswordHash || after.Role != before.Role ||
|
||||
after.Email != before.Email || after.DMARCDefaultMode != before.DMARCDefaultMode ||
|
||||
after.Email != before.Email ||
|
||||
!equalReach(after.Reach(), before.Reach()) {
|
||||
t.Errorf("saving untouched changed the user:\nbefore %+v\nafter %+v", before, after)
|
||||
}
|
||||
|
||||
@@ -93,12 +93,7 @@ func (h *Handlers) overviewDomains(domains []store.Domain, retention int) []view
|
||||
logf("panel: overview: domain %d: dkim record: %v", d.ID, err)
|
||||
return
|
||||
}
|
||||
reportEmail, err := h.domainReportAddress(d)
|
||||
if err != nil {
|
||||
logf("panel: overview: domain %d: dmarc report address: %v", d.ID, err)
|
||||
return
|
||||
}
|
||||
dns, _ := h.domainDNS(d, record, reportEmail, false)
|
||||
dns, _ := h.domainDNS(d, record, d.DMARCRua, false)
|
||||
rows[i].DNS = dnsTags(dns.DKIM.Status, dns.SPF.Status, dns.DMARC.Status)
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -310,7 +310,6 @@ func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u st
|
||||
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not save user. Please check the logs.", FormUsername: username, FormEmail: email, FormRole: string(role), Reach: reach})
|
||||
return
|
||||
}
|
||||
h.resyncAfterEmailChange(u, email)
|
||||
if password != "" {
|
||||
h.endSessionsAfterPasswordSet(r, p, u)
|
||||
}
|
||||
@@ -384,18 +383,6 @@ func (h *Handlers) HandleUserDeleteConfirm(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
}
|
||||
// Domains that follow this user's default report address are left with none.
|
||||
domains, err := h.store.ListDomains()
|
||||
if err != nil {
|
||||
logf("panel: delete user %d: list domains: %v", u.ID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
for _, d := range domains {
|
||||
if d.DMARCRuaUserID.Valid && d.DMARCRuaUserID.Int64 == u.ID {
|
||||
in.Following = append(in.Following, d.Name)
|
||||
}
|
||||
}
|
||||
h.view.Render(w, http.StatusOK, "user-delete", view.NewUserDelete(h.shellMeta(r), in))
|
||||
}
|
||||
|
||||
|
||||
@@ -3,18 +3,14 @@ package view
|
||||
// The data of Account (templates/account.html, mockup account.html) and of
|
||||
// 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
|
||||
// user's own — who they are to the panel, their password and where DMARC reports
|
||||
// about their domains go — and every role has it. Settings is the instance's.
|
||||
// user's own — who they are to the panel and their password — and every role
|
||||
// has it. Settings is the instance's.
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
import "strconv"
|
||||
|
||||
// Account is the data of the Account page. Three forms, each its own POST: the
|
||||
// profile (Username, Email), the password, and the default report address
|
||||
// (DMARCSelected, DMARCAddress). The form fields hold what was stored, or what
|
||||
// was just submitted when a form is shown again with an error.
|
||||
// Account is the data of the Account page. Two forms, each its own POST: the
|
||||
// profile (Username, Email) and the password. The fields hold what was stored,
|
||||
// or what was just submitted when a form is shown again with an error.
|
||||
type Account struct {
|
||||
Meta
|
||||
Head Head
|
||||
@@ -25,122 +21,28 @@ type Account struct {
|
||||
Email string
|
||||
|
||||
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
|
||||
// appears in the kicker. hosted says whether SelfPost-hosted reports are
|
||||
// offered (report ingest is on), accountEmail is the e-mail the "my account
|
||||
// e-mail" choice names.
|
||||
func NewAccount(m Meta, role string, hosted bool, accountEmail string) *Account {
|
||||
// appears in the kicker.
|
||||
func NewAccount(m Meta, role string) *Account {
|
||||
m.Title, m.Section, m.Page = "Account", "user", "account"
|
||||
a := &Account{
|
||||
return &Account{
|
||||
Meta: m,
|
||||
Head: Head{
|
||||
Kicker: "Signed in as " + m.User + " · " + role,
|
||||
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"},
|
||||
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
|
||||
// head and the first box.
|
||||
func (a *Account) WithResult(flash, formErr string) *Account {
|
||||
switch {
|
||||
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
|
||||
a.Flash = resultFlash(flash, formErr)
|
||||
return a
|
||||
}
|
||||
|
||||
@@ -169,7 +71,7 @@ func NewSettings(m Meta, retention, level1 string) *Settings {
|
||||
Head: Head{
|
||||
Kicker: "Server",
|
||||
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"), "."),
|
||||
},
|
||||
Retention: Box{No: "01", Title: "Log retention", Help: topicLink(HelpSettings, "How long rows are kept")},
|
||||
|
||||
@@ -198,6 +198,18 @@ type Action struct {
|
||||
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
|
||||
// and the Level (ok, warn or fail) that colours it.
|
||||
type Postmark struct {
|
||||
|
||||
@@ -120,15 +120,12 @@ func healthFixture(refresh bool) *Health {
|
||||
return h
|
||||
}
|
||||
|
||||
// accountFixture is the mockup's Account: the global administrator, their
|
||||
// e-mail as the DMARC default, and the authorization record that default needs.
|
||||
// accountFixture is the mockup's Account: the global administrator, their name
|
||||
// and e-mail.
|
||||
func accountFixture() *Account {
|
||||
m := admin()
|
||||
a := NewAccount(m, "global", true, "mix@example.org")
|
||||
a := NewAccount(admin(), "global")
|
||||
a.Username, a.Email = "admin", "mix@example.org"
|
||||
a.DMARCSelected = DMARCChoiceAccount
|
||||
a.WithDomainUse(2, 3)
|
||||
return a.WithAuthorization("*._report._dmarc.example.org", "v=DMARC1;", "ok", "", nil)
|
||||
return a
|
||||
}
|
||||
|
||||
// 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",
|
||||
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"}}),
|
||||
DMARCRecord(DMARCRecordInput{Host: "_dmarc.example.org", Value: "v=DMARC1; p=none; rua=mailto:dmarc@mail.example.org",
|
||||
Check: DNSCheck{Status: "ok"}, Source: "admin's default", SettingsHref: "/outbound/domains/1/settings",
|
||||
DMARCRecord(DMARCRecordInput{Host: "_dmarc.example.org", Value: "v=DMARC1; p=none; rua=mailto:example.org@dmarc.mail.example.org",
|
||||
Check: DNSCheck{Status: "ok"}, Source: "example.org@dmarc.mail.example.org", SettingsHref: "/outbound/domains/1/settings",
|
||||
ReportsHref: "/outbound/dmarc/domains/1"}),
|
||||
}, "3 min ago")
|
||||
p.WithApplications([]OutAppRow{
|
||||
{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),
|
||||
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.WithSeeAlso("/outbound/log?domain=example.org", "/outbound/dmarc/domains/1")
|
||||
@@ -182,17 +181,15 @@ func outDomainFixture() *OutDomain {
|
||||
return p
|
||||
}
|
||||
|
||||
// outDomainSettingsFixture is the mockup's Domain settings: reports go to the
|
||||
// default of the signed-in administrator, the rate limit is automatic.
|
||||
// outDomainSettingsFixture is the mockup's Domain settings: reports go to an
|
||||
// address typed for the domain, the rate limit is automatic.
|
||||
func outDomainSettingsFixture() *OutDomainSettings {
|
||||
p := NewOutDomainSettings(admin(), 1, "example.org", 2, true)
|
||||
p.WithReportAddress([]Option{
|
||||
{Value: "inherit", Label: "admin's default — mix@example.org"},
|
||||
{Value: "hosted", Label: "SelfPost hosted (example.org@dmarc.mail.example.org)"},
|
||||
{Value: "none", Label: "No aggregate reports"},
|
||||
{Value: "custom", Label: "Custom address"},
|
||||
}, "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."))
|
||||
{Value: ReportNone, Label: "No reports"},
|
||||
{Value: ReportHosted, Label: "SelfPost hosted — example.org@dmarc.mail.example.org"},
|
||||
{Value: ReportCustom, Label: "A specific address"},
|
||||
}, ReportCustom, "reports@acme.io", "mix@example.org")
|
||||
p.WithExport(12)
|
||||
return p.WithRateLimit(DomainRateLimit{
|
||||
Active: true, Auto: true, MaxMessages: "288", Window: "3600", Multiplier: "2.5",
|
||||
|
||||
@@ -31,6 +31,7 @@ type Kit struct {
|
||||
Records []Record
|
||||
Tables Box
|
||||
TablesFoot Foot
|
||||
RowAction RowAction
|
||||
LogPane Box
|
||||
LogLines []LogLine
|
||||
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 @")},
|
||||
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")},
|
||||
LogLines: []LogLine{
|
||||
|
||||
@@ -164,7 +164,7 @@ func SPFRecord(domain, value string, c DNSCheck) Record {
|
||||
}
|
||||
|
||||
// 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 report address is on the sending domain with nobody to read it there.
|
||||
type DMARCRecordInput struct {
|
||||
@@ -319,7 +319,7 @@ type OutDomain struct {
|
||||
Days int
|
||||
AppRows []OutAppRow
|
||||
AppsEmpty EmptyState
|
||||
AppsLegend Foot
|
||||
AppsFoot Foot
|
||||
|
||||
Connection Box
|
||||
ConnFacts []Fact
|
||||
@@ -332,15 +332,15 @@ type OutDomain struct {
|
||||
// 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 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
|
||||
// are one click away: both are POSTs, and a row of a table has no control for
|
||||
// those (only links).
|
||||
// Edit leads to the application's page; New password and Delete are POSTs in
|
||||
// the row itself (RowAction), each asking before it is sent.
|
||||
type OutAppRow struct {
|
||||
Login string
|
||||
Senders []string
|
||||
Activity string
|
||||
Limits []Tag
|
||||
Edit string
|
||||
Actions []RowAction
|
||||
}
|
||||
|
||||
// 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"),
|
||||
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.")},
|
||||
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")},
|
||||
}
|
||||
}
|
||||
@@ -529,6 +530,9 @@ type OutDomainSettings struct {
|
||||
RuaSelected string
|
||||
RuaCustom string
|
||||
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
|
||||
ExportAction string
|
||||
@@ -638,11 +642,30 @@ func NewOutDomainSettings(m Meta, id int64, name string, apps int, canDelete boo
|
||||
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
|
||||
// option is what the handler reads), the one selected, the custom address
|
||||
// typed, and the sentence under the select.
|
||||
func (p *OutDomainSettings) WithReportAddress(options []Option, selected, custom string, help Text) *OutDomainSettings {
|
||||
p.RuaOptions, p.RuaSelected, p.RuaCustom, p.RuaHelp = options, selected, custom, help
|
||||
// option is what the handler reads), the one selected, the address typed for
|
||||
// the domain, and the profile e-mail of the viewer that the button beside the
|
||||
// field fills it with ("" for none). The sentence under the select says what
|
||||
// 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
|
||||
}
|
||||
|
||||
|
||||
@@ -154,6 +154,27 @@ func NewOutApp(m Meta, domainID int64, domain, login string) *OutApp {
|
||||
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
|
||||
// head's buttons (a new password and delete, each asking first) and the mail of
|
||||
// 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.Head.Lead = StatsLead(total, peak, avg, days)
|
||||
p.Head.Actions = []Action{
|
||||
{Label: "New password", Icon: "ti-key", Post: own + "/password",
|
||||
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: "Delete application " + p.Head.Title + "? Its credentials stop working immediately."},
|
||||
{Label: "New password", Icon: "ti-key", Post: own + "/password", Confirm: newPasswordQuestion(p.Head.Title)},
|
||||
{Label: "Delete", Icon: "ti-trash", Post: own + "/delete", Danger: true, Confirm: deleteAppQuestion(p.Head.Title)},
|
||||
}
|
||||
p.recalc = own + "/ratelimit/recalc"
|
||||
return p
|
||||
|
||||
@@ -99,7 +99,7 @@ func TestOutDomainRecordsSayWhatDNSHolds(t *testing.T) {
|
||||
`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`,
|
||||
`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'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>`,
|
||||
)
|
||||
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."}),
|
||||
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."},
|
||||
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."}),
|
||||
}
|
||||
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())
|
||||
pageHas(t, "Domain", out,
|
||||
`<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",
|
||||
`<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 state change is never a link, and a table row has no control for one:
|
||||
// a new password and deleting an application are on the application's page.
|
||||
pageLacks(t, "Domain", out, `/password`, `/applications/1/delete`)
|
||||
// A state change is never a link: New password and Delete are POST forms in
|
||||
// the row, each with the question panel.js asks first, drawn like the Edit
|
||||
// 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)
|
||||
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> /`,
|
||||
`id="reports"`, `id="limit"`, `id="export"`,
|
||||
`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's default — mix@example.org</option>`,
|
||||
`<option value="hosted">`, `<option value="none">`, `<option value="custom">`, `href="/account#dmarc"`,
|
||||
`name="dmarc_rua_email"`, `value="reports@acme.io"`, `<option value="none">No reports</option>`,
|
||||
`<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`,
|
||||
`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`,
|
||||
@@ -260,11 +271,25 @@ func TestOutDomainSettingsStatesTheFixtureDoesNotDraw(t *testing.T) {
|
||||
pageLacks(t, "Settings", out, `Delete domain`, `/delete`)
|
||||
|
||||
// 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.")
|
||||
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>`)
|
||||
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"><i>@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) {
|
||||
|
||||
@@ -250,57 +250,25 @@ func TestNewHealthFlash(t *testing.T) {
|
||||
|
||||
// ---- Account
|
||||
|
||||
func TestAccountHasThreeFormsEachItsOwnPost(t *testing.T) {
|
||||
func TestAccountHasTwoFormsEachItsOwnPost(t *testing.T) {
|
||||
out := renderSignedIn(t, "account", accountFixture())
|
||||
pageHas(t, "Account", out,
|
||||
`<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/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`,
|
||||
`<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's default</em>`,
|
||||
`Your own address. A domain's report address can be filled with it from Domain settings.`,
|
||||
)
|
||||
pageLacks(t, "Account", out, `send_log_retention_days`)
|
||||
// No form asks for another's fields: three forms, three submit buttons.
|
||||
if got := strings.Count(out, `<form class="sp-form" method="post"`); got != 3 {
|
||||
t.Errorf("Account has %d forms, want 3", got)
|
||||
// Account is Profile and Password; where a domain's reports go is the
|
||||
// domain's own setting.
|
||||
pageLacks(t, "Account", out, `send_log_retention_days`, `/account/dmarc`, `dmarc_default`, `<h2>DMARC reports</h2>`,
|
||||
`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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountReportAuthorizationRecord(t *testing.T) {
|
||||
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`, `"v=DMARC2"`, `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")
|
||||
// Two equal columns, one box each.
|
||||
if got := strings.Count(out, `<div class="column is-6">`); got != 2 {
|
||||
t.Errorf("Account has %d equal columns, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,9 +284,9 @@ func TestAccountResultAndRefusalAreTheFlash(t *testing.T) {
|
||||
// What a person typed comes back escaped when a form is shown again.
|
||||
func TestAccountEscapesTheFormValues(t *testing.T) {
|
||||
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)
|
||||
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
|
||||
|
||||
@@ -359,9 +359,6 @@ type UserDeleteInput struct {
|
||||
AllInbound bool
|
||||
Outbound []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.
|
||||
@@ -386,7 +383,7 @@ func NewUserDelete(m Meta, in UserDeleteInput) *UserDelete {
|
||||
}
|
||||
if in.Global {
|
||||
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
|
||||
switch {
|
||||
@@ -409,39 +406,6 @@ func NewUserDelete(m Meta, in UserDeleteInput) *UserDelete {
|
||||
case len(inb) > 0:
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -316,34 +316,18 @@ func TestEveryHelpLinkLandsOnATopic(t *testing.T) {
|
||||
// change it under Account, and it can receive DMARC reports if they choose.
|
||||
func TestUserFormEmailHelpSaysOnlyWhatIsTrue(t *testing.T) {
|
||||
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`)
|
||||
}
|
||||
|
||||
// With domains that follow the user's default report address, deleting them
|
||||
// leaves those domains with none, and the page says so: up to three names, then
|
||||
// the count of the rest. With none following, the page says nothing about it.
|
||||
func TestUserDeleteSaysWhichDomainsLoseTheirReportAddress(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
following []string
|
||||
want string
|
||||
// A domain's report address belongs to the domain, not to a user: deleting a
|
||||
// user, global or not, says nothing about any domain losing one.
|
||||
func TestUserDeleteSaysNothingOfReportAddresses(t *testing.T) {
|
||||
for _, in := range []UserDeleteInput{
|
||||
{ID: 3, Username: "acme-ops", AllOutbound: true, Outbound: []string{"example.org"}},
|
||||
{ID: 2, Username: "root", Global: true},
|
||||
} {
|
||||
{[]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))
|
||||
pageHas(t, "Delete user", out, tt.want)
|
||||
if strings.Contains(out, "d.example") && len(tt.following) > 3 {
|
||||
t.Errorf("a fourth name is spelled out: %v", tt.following)
|
||||
pageLacks(t, "Delete user", out, `report address`, `will have no`, `The domains`)
|
||||
}
|
||||
}
|
||||
// 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`)
|
||||
}
|
||||
|
||||
@@ -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: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 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; }
|
||||
.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); }
|
||||
|
||||
@@ -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 --------------------------------
|
||||
// Forms that delete something or invalidate a working credential carry a
|
||||
// data-confirm message. The prompt lives here rather than in an inline
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
{{/* 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
|
||||
own POST and none of them asks for the others' fields. Every role has this
|
||||
page; the instance's settings are on Server > Settings (settings.html). */}}
|
||||
and password. Each form is its own POST and neither asks for the other's
|
||||
fields. Every role has this page; the instance's settings are on Server >
|
||||
Settings (settings.html). */}}
|
||||
{{define "content"}}
|
||||
{{template "page_head" .Head}}
|
||||
{{if .Flash}}{{template "flash" .Flash}}{{end}}
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-5">
|
||||
<div class="column is-6">
|
||||
{{template "box_open" .Profile}}
|
||||
{{template "body_open"}}
|
||||
<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="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>
|
||||
</form>
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
</div>
|
||||
|
||||
<div class="column is-6">
|
||||
{{template "box_open" .Password}}
|
||||
{{template "body_open"}}
|
||||
<form class="sp-form" method="post" action="/account/password">
|
||||
@@ -33,23 +35,5 @@
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
</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>
|
||||
{{end}}
|
||||
|
||||
@@ -36,6 +36,11 @@
|
||||
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}}
|
||||
|
||||
{{/* 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 opens every page (input: Head): the path or section as the
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
<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>
|
||||
<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>
|
||||
</table></div>
|
||||
{{template "box_foot" .TablesFoot}}
|
||||
|
||||
@@ -22,14 +22,15 @@
|
||||
<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>
|
||||
<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>
|
||||
<td class="sp-actions"><a href="{{.Edit}}">Edit</a></td></tr>{{end}}
|
||||
<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>{{range .Actions}}{{template "row_action" .}}{{end}}</td></tr>{{end}}
|
||||
</tbody>
|
||||
</table></div>
|
||||
{{else}}
|
||||
{{template "empty_state" .AppsEmpty}}
|
||||
{{end}}
|
||||
{{template "box_close"}}
|
||||
{{if .AppRows}}{{template "box_foot" .AppsFoot}}
|
||||
{{end}}{{template "box_close"}}
|
||||
|
||||
{{template "box_open" .Connection}}
|
||||
{{template "body_open"}}
|
||||
|
||||
@@ -16,8 +16,9 @@
|
||||
<option value="{{.Value}}"{{if eq .Value $sel}} selected{{end}}>{{.Label}}</option>{{end}}
|
||||
</select></div></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="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" data-custom-address><label class="label" for="rua_email">Report address</label>
|
||||
<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>
|
||||
</form>
|
||||
{{template "body_close"}}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
{{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="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>
|
||||
<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>
|
||||
|
||||
@@ -188,7 +188,6 @@ func (s *Server) muxes() (public, authed *http.ServeMux) {
|
||||
authed.HandleFunc("GET /account", h.HandleAccount)
|
||||
authed.HandleFunc("POST /account/profile", h.HandleAccountProfile)
|
||||
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
|
||||
// but does not live under /server/.
|
||||
|
||||