diff --git a/CHANGELOG.md b/CHANGELOG.md
index 214199c..4d5689d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -48,19 +48,30 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
tenant's domain, a missing one and a user with no inbound reach all get the
same 404. A menu group with nothing assigned is absent, and `/` leads an
inbound-only administrator to Inbound.
+- panel-redesign (stage 1): **Account** (`/account`, every role) and
+ **Server › Settings** (`/server/settings`, global role) replace the one
+ Settings page. Account has three forms, each its own POST: profile
+ (username, e-mail), password, and the default DMARC report address
+ (SelfPost hosted, my account e-mail, another address, none). Saving the
+ profile no longer asks for the password; changing the password still does.
### Changed
+- **Breaking — every panel URL changed.** A path now reads like the menu
+ (`/overview`, `/outbound/…`, `/inbound/…`, `/server/…`, `/account`); the
+ old paths are removed, not redirected — see *Removed*. Polled fragments all
+ answer `/fragment`. Everything under `/server/` is guarded for the
+ global role where the routes are registered, in addition to each handler's
+ own check. The application routes and Server › Health follow in the next
+ two steps.
- **Breaking — the database starts from zero.** The 1.x migration chain
(`0001`–`0010`) is replaced by one baseline, `0001_init.sql`. A 1.x
`/data/selfpost.db` is refused at start-up with an explicit error and left
untouched; nothing is migrated. A domain can be carried over with its
export file.
- The panel role `domain_admin` is stored as `domain`.
-- Until the Account page replaces it, the old Settings form's "default report
- address" field sets the account e-mail and switches the user's DMARC
- default between that e-mail and none; a domain's "Same as Settings" makes
- it follow the user who chose it.
+- On the old domain page, "Same as Settings" makes the domain follow the
+ default report address of the user who chose it.
- The old panel is untouched on screen but steps aside in the tree: its
stylesheet is `legacy.css` and its layout `layout_legacy.html` until the
last old page is restyled. The 2.0 menu paths in the new shell do not
@@ -70,6 +81,15 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
### Removed
+- Old panel paths, GET and POST alike, without redirects: `/status`,
+ `/status/fragment`, `/status/recheck`, `/reload`, `/domains` and everything
+ under it (`/domains/import`, `/domains/{id}`, `…/dns-recheck`, `…/delete`,
+ `…/ratelimit`, `…/ratelimit/recalc`, `…/dmarc`, `…/export`), `/inbound` and
+ `/inbound/{id}/…`, `/dmarc`, `/dmarc/reports/{id}`, `/dmarc/domains/{id}`,
+ `/deliveries`, `/deliveries/rows`, `/deliveries/{id}`, `/mail-queue`,
+ `/mail-queue/body`, `/system-log`, `/system-log/body`, `/backup`, `/users`
+ and everything under it, `/settings`, and the `/account` → `/settings`
+ redirect.
- The instance setting `dmarc_report_email` (a mirror of the global user's
profile field) and the unused `rate_limits.allowed_ips` column.
diff --git a/cmd/panel/restore_test.go b/cmd/panel/restore_test.go
index 5575d1b..ef562eb 100644
--- a/cmd/panel/restore_test.go
+++ b/cmd/panel/restore_test.go
@@ -74,7 +74,7 @@ func restoreFromOwnBackup(t *testing.T, password string) restored {
func TestPanelBootsOnADataDirectoryRestoredFromItsOwnBackup(t *testing.T) {
r := restoreFromOwnBackup(t, "")
- body := getPage(t, r.panel, "/deliveries", signIn(t, r.panel))
+ body := getPage(t, r.panel, "/outbound/log", signIn(t, r.panel))
for _, want := range []string{restoreDomain, restoreSubject} {
if !strings.Contains(body, want) {
t.Errorf("the restored panel's send log does not show %q:\n%s", want, body)
@@ -128,7 +128,7 @@ func TestARestoreDoesNotReopenTheSetupLink(t *testing.T) {
func TestARestoredPanelHonoursSessionsFromTheArchive(t *testing.T) {
r := restoreFromOwnBackup(t, "")
- rec := request(t, r.panel, http.MethodGet, "/deliveries", nil, r.session)
+ rec := request(t, r.panel, http.MethodGet, "/outbound/log", nil, r.session)
if rec.Code != http.StatusOK {
t.Errorf("a session from before the backup = %d on the restored panel, want 200", rec.Code)
}
@@ -141,7 +141,7 @@ func TestARestoredPanelHonoursSessionsFromTheArchive(t *testing.T) {
func TestAnEncryptedBackupRestoresTheSameWay(t *testing.T) {
r := restoreFromOwnBackup(t, "a-long-enough-password")
- body := getPage(t, r.panel, "/deliveries", signIn(t, r.panel))
+ body := getPage(t, r.panel, "/outbound/log", signIn(t, r.panel))
if !strings.Contains(body, restoreSubject) {
t.Errorf("the panel restored from an encrypted backup lost the send log:\n%s", body)
}
@@ -384,7 +384,7 @@ func downloadBackup(t *testing.T, h http.Handler, session *http.Cookie, password
form.Set("password", password)
form.Set("password_confirm", password)
}
- rec := request(t, h, http.MethodPost, "/backup", strings.NewReader(form.Encode()), session)
+ rec := request(t, h, http.MethodPost, "/server/backup", strings.NewReader(form.Encode()), session)
if rec.Code != http.StatusOK {
t.Fatalf("download a backup = %d, want 200:\n%s", rec.Code, rec.Body.String())
}
diff --git a/docs/plans/panel-redesign.md b/docs/plans/panel-redesign.md
index fc35947..82e5c79 100644
--- a/docs/plans/panel-redesign.md
+++ b/docs/plans/panel-redesign.md
@@ -507,7 +507,7 @@ only when both are done. Every stage-2 step ends with the evidence of
- [x] Schema from zero: single `0001_init.sql` baseline with `users.email`, the DMARC default fields, `user_inbound_domains`, the two `all_*` flags; store tests; `schema-migrations.md` rewritten — **Opus**
- [x] Inbound delegation: `requireInboundDomain`, lists and log filtered in the query, two-list user form data, per-route 404 tests for both roles including another tenant's id — **Opus**
-- [ ] Routes renamed per § Routes: new paths, one fragment name, old paths removed, subtree `requireGlobal()` for `/server/`; links in the old templates retargeted so the panel works on every commit — **Opus**
+- [x] Routes renamed per § Routes: new paths, one fragment name, old paths removed, subtree `requireGlobal()` for `/server/`; links in the old templates retargeted so the panel works on every commit — **Opus**
- [ ] The application form as one POST; `out-app-created` as the response with `Cache-Control: no-store` — **Opus**
- [ ] Server › Health route and handler (tables and *Reload configuration* from `/status`); Overview handler reduced to verdicts — **Opus**
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 30d57a7..10e9616 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -39,7 +39,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
| panel-notifications | E-mail notifications about important events | candidate | — | — |
| delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | candidate | — | — |
| password-reset | Password reset by e-mail | candidate | — | — |
-| panel-redesign | Panel redesign (accepted mockups, design contract) | agreed | 12/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |
+| panel-redesign | Panel redesign (accepted mockups, design contract) | agreed | 13/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |
| schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [plans/panel-redesign.md](plans/panel-redesign.md) |
**Recommended order** (not binding; owner, 2026-10-09: feature order follows
diff --git a/internal/web/handlers/authz_test.go b/internal/web/handlers/authz_test.go
index 7a96172..e8fe848 100644
--- a/internal/web/handlers/authz_test.go
+++ b/internal/web/handlers/authz_test.go
@@ -26,38 +26,38 @@ type route struct {
var globalOnlyRoutes = []route{
{"GET", "/server/components", func(h *Handlers) http.HandlerFunc { return h.HandleComponents }, nil},
- {"GET", "/users", func(h *Handlers) http.HandlerFunc { return h.HandleUsers }, nil},
- {"GET", "/users/new", func(h *Handlers) http.HandlerFunc { return h.HandleUserNew }, nil},
- {"POST", "/users/new", func(h *Handlers) http.HandlerFunc { return h.HandleUserNew }, nil},
- {"GET", "/users/1", func(h *Handlers) http.HandlerFunc { return h.HandleUserEdit }, map[string]string{"uid": "1"}},
- {"POST", "/users/1", func(h *Handlers) http.HandlerFunc { return h.HandleUserEdit }, map[string]string{"uid": "1"}},
- {"GET", "/users/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleUserDeleteConfirm }, map[string]string{"uid": "1"}},
- {"POST", "/users/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleUserDelete }, map[string]string{"uid": "1"}},
+ {"GET", "/server/users", func(h *Handlers) http.HandlerFunc { return h.HandleUsers }, nil},
+ {"GET", "/server/users/new", func(h *Handlers) http.HandlerFunc { return h.HandleUserNew }, nil},
+ {"POST", "/server/users/new", func(h *Handlers) http.HandlerFunc { return h.HandleUserNew }, nil},
+ {"GET", "/server/users/1", func(h *Handlers) http.HandlerFunc { return h.HandleUserEdit }, map[string]string{"uid": "1"}},
+ {"POST", "/server/users/1", func(h *Handlers) http.HandlerFunc { return h.HandleUserEdit }, map[string]string{"uid": "1"}},
+ {"GET", "/server/users/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleUserDeleteConfirm }, map[string]string{"uid": "1"}},
+ {"POST", "/server/users/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleUserDelete }, map[string]string{"uid": "1"}},
- {"GET", "/backup", func(h *Handlers) http.HandlerFunc { return h.HandleBackupPage }, nil},
- {"POST", "/backup", func(h *Handlers) http.HandlerFunc { return h.HandleBackup }, nil},
- {"POST", "/domains/import", func(h *Handlers) http.HandlerFunc { return h.HandleImportDomain }, nil},
+ {"GET", "/server/backup", func(h *Handlers) http.HandlerFunc { return h.HandleBackupPage }, nil},
+ {"POST", "/server/backup", func(h *Handlers) http.HandlerFunc { return h.HandleBackup }, nil},
+ {"POST", "/server/backup/import", func(h *Handlers) http.HandlerFunc { return h.HandleImportDomain }, nil},
- {"GET", "/status", func(h *Handlers) http.HandlerFunc { return h.HandleStatus }, nil},
- {"GET", "/status/fragment", func(h *Handlers) http.HandlerFunc { return h.HandleStatusFragment }, nil},
- {"POST", "/status/recheck", func(h *Handlers) http.HandlerFunc { return h.HandleStatusRecheck }, nil},
+ {"GET", "/overview", func(h *Handlers) http.HandlerFunc { return h.HandleStatus }, nil},
+ {"GET", "/overview/fragment", func(h *Handlers) http.HandlerFunc { return h.HandleStatusFragment }, nil},
+ {"POST", "/server/health/recheck", func(h *Handlers) http.HandlerFunc { return h.HandleStatusRecheck }, nil},
- {"GET", "/mail-queue", func(h *Handlers) http.HandlerFunc { return h.HandleMailQueue }, nil},
- {"GET", "/mail-queue/body", func(h *Handlers) http.HandlerFunc { return h.HandleMailQueueBody }, nil},
- {"GET", "/system-log", func(h *Handlers) http.HandlerFunc { return h.HandleSystemLog }, nil},
- {"GET", "/system-log/body", func(h *Handlers) http.HandlerFunc { return h.HandleSystemLogBody }, nil},
+ {"GET", "/outbound/queue", func(h *Handlers) http.HandlerFunc { return h.HandleMailQueue }, nil},
+ {"GET", "/outbound/queue/fragment", func(h *Handlers) http.HandlerFunc { return h.HandleMailQueueBody }, nil},
+ {"GET", "/server/log", func(h *Handlers) http.HandlerFunc { return h.HandleSystemLog }, nil},
+ {"GET", "/server/log/fragment", func(h *Handlers) http.HandlerFunc { return h.HandleSystemLogBody }, nil},
- {"POST", "/domains", func(h *Handlers) http.HandlerFunc { return h.HandleAddDomain }, nil},
- {"GET", "/domains/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleDeleteConfirm }, map[string]string{"id": "1"}},
- {"POST", "/domains/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleDeleteDomain }, map[string]string{"id": "1"}},
- {"POST", "/reload", func(h *Handlers) http.HandlerFunc { return h.HandleReload }, nil},
+ {"POST", "/outbound/domains", func(h *Handlers) http.HandlerFunc { return h.HandleAddDomain }, nil},
+ {"GET", "/outbound/domains/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleDeleteConfirm }, map[string]string{"id": "1"}},
+ {"POST", "/outbound/domains/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleDeleteDomain }, map[string]string{"id": "1"}},
+ {"POST", "/server/health/reload", func(h *Handlers) http.HandlerFunc { return h.HandleReload }, nil},
// Inbound: only adding and deleting a domain are global. The list and the
// pages of one inbound domain are delegated per domain and have their own
// table in handlers_delegation_test.go.
- {"POST", "/inbound", func(h *Handlers) http.HandlerFunc { return h.HandleAddInbound }, nil},
- {"GET", "/inbound/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleInboundDeleteConfirm }, map[string]string{"id": "1"}},
- {"POST", "/inbound/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleInboundDelete }, map[string]string{"id": "1"}},
+ {"POST", "/inbound/domains", func(h *Handlers) http.HandlerFunc { return h.HandleAddInbound }, nil},
+ {"GET", "/inbound/domains/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleInboundDeleteConfirm }, map[string]string{"id": "1"}},
+ {"POST", "/inbound/domains/1/delete", func(h *Handlers) http.HandlerFunc { return h.HandleInboundDelete }, map[string]string{"id": "1"}},
}
// A domain administrator has an account on the panel, so authentication is not
@@ -102,7 +102,7 @@ func TestGlobalOnlyRoutesAnswerAnUnknownPrincipal404(t *testing.T) {
func TestGlobalOnlyRoutesOpenForAGlobalAdministrator(t *testing.T) {
h, _ := serverWithTwoDomains(t)
- for _, target := range []string{"/users", "/backup"} {
+ for _, target := range []string{"/server/users", "/server/backup"} {
rt := getRoute(t, target)
if rec := call(h, rt, globalPrincipal); rec.Code != http.StatusOK {
t.Errorf("GET %s as a global administrator = %d, want 200:\n%s",
diff --git a/internal/web/handlers/e2e_markup_probe_test.go b/internal/web/handlers/e2e_markup_probe_test.go
index 17c8a9f..c2a50a9 100644
--- a/internal/web/handlers/e2e_markup_probe_test.go
+++ b/internal/web/handlers/e2e_markup_probe_test.go
@@ -10,7 +10,7 @@ import (
// checks whether the e2e gate's HTML scrapers still match it.
func TestE2ESendLogStatusMarkupDrift(t *testing.T) {
h, _ := serverWithDelivery(t)
- rendered := getBody(t, h.HandleDeliveriesRows, "/deliveries/rows")
+ rendered := getBody(t, h.HandleDeliveriesRows, "/outbound/log/fragment")
// These mirror test/e2e/main_test.go — keep in sync when fixing the e2e gate.
statusCellPattern := regexp.MustCompile(`class="st st-[^"]+">(queued|sent|deferred|bounced|rejected)`)
diff --git a/internal/web/handlers/handlers_account.go b/internal/web/handlers/handlers_account.go
new file mode 100644
index 0000000..290929d
--- /dev/null
+++ b/internal/web/handlers/handlers_account.go
@@ -0,0 +1,276 @@
+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"
+ "golang.org/x/crypto/bcrypt"
+)
+
+// 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.
+//
+// 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).
+
+// accountForm is what the page shows back: the stored values, or the ones just
+// submitted when a form is re-rendered with an error.
+type accountForm struct {
+ Err string
+ Username string
+ Email string
+ DMARCMode string
+ DMARCAddress string
+}
+
+func (h *Handlers) accountUser(w http.ResponseWriter, r *http.Request) (store.User, bool) {
+ p, ok := h.principal(r)
+ if !ok {
+ http.NotFound(w, r)
+ return store.User{}, false
+ }
+ u, err := h.store.GetUser(p.ID)
+ if err != nil {
+ logf("panel: account: get user %d: %v", p.ID, err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return store.User{}, false
+ }
+ return u, true
+}
+
+func formOf(u store.User) accountForm {
+ return accountForm{Username: u.Username, Email: u.Email, DMARCMode: u.DMARCDefaultMode, DMARCAddress: u.DMARCDefaultAddress}
+}
+
+// HandleAccount shows the Account page.
+func (h *Handlers) HandleAccount(w http.ResponseWriter, r *http.Request) {
+ u, ok := h.accountUser(w, r)
+ if !ok {
+ return
+ }
+ h.renderAccount(w, r, http.StatusOK, u, formOf(u))
+}
+
+func (h *Handlers) renderAccount(w http.ResponseWriter, r *http.Request, status int, u store.User, f accountForm) {
+ data := h.pageBase(r)
+ data["Title"] = "SelfPost — account"
+ data["Active"] = "account"
+ data["AccountPage"] = true
+ data["Role"] = u.Role
+ data["FormUsername"] = f.Username
+ data["FormEmail"] = f.Email
+ data["FormDMARCMode"] = f.DMARCMode
+ data["FormDMARCAddress"] = f.DMARCAddress
+ data["AccountEmail"] = u.Email
+ data["Error"] = f.Err
+ data["Flash"] = accountFlash(r)
+ data["DMARCIngestEnabled"] = h.dmarc != nil && h.cfg.DMARCEnabled
+
+ // 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)
+ data["ReportAuthDNS"] = h.dns.ReportAuth(ctx, hub)
+ cancel()
+ data["ReportAuthName"] = dnscheck.ReportAuthRecordName(hub)
+ data["ReportAuthExample"] = dnscheck.ReportAuthExample()
+ data["ReportAuthHub"] = hub
+ }
+ }
+ h.view.Render(w, status, "settings", data)
+}
+
+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 ""
+}
+
+// HandleAccountProfile saves the username and the account e-mail.
+func (h *Handlers) HandleAccountProfile(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.Username = strings.TrimSpace(r.PostFormValue("username"))
+ f.Email = strings.TrimSpace(r.PostFormValue("email"))
+ if f.Username == "" {
+ f.Username = u.Username
+ }
+ if f.Username != u.Username {
+ if err := validate.Username(f.Username); err != nil {
+ fail(http.StatusBadRequest, err.Error())
+ return
+ }
+ }
+ if err := validate.Email(f.Email); err != nil {
+ fail(http.StatusBadRequest, err.Error())
+ return
+ }
+ if err := h.store.UpdateUser(u.ID, f.Username, u.PasswordHash, f.Email); err != nil {
+ if errors.Is(err, store.ErrUserExists) {
+ fail(http.StatusConflict, "That username is already in use.")
+ return
+ }
+ logf("panel: account: update user %d: %v", u.ID, err)
+ fail(http.StatusInternalServerError, "Could not save the profile. Please check the logs and try again.")
+ return
+ }
+ if f.Username != u.Username {
+ if token, ok := h.auth.SessionToken(r); ok {
+ h.auth.RenameSession(token, f.Username)
+ }
+ }
+ // The e-mail is the report address of every domain that follows this
+ // user's "my account e-mail" default.
+ if f.Email != u.Email && u.DMARCDefaultMode == store.DMARCDefaultAccount {
+ h.resyncDMARC("account e-mail")
+ }
+ 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)
+}
+
+// HandleAccountPassword changes the password. It asks for the current one and
+// is rate-limited like a sign-in: the form is a place to guess a password from
+// an unattended session.
+func (h *Handlers) HandleAccountPassword(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 !h.auth.AllowLoginAttempt(r) {
+ fail(http.StatusTooManyRequests, "Too many attempts. Please wait and try again.")
+ return
+ }
+ if err := r.ParseForm(); err != nil {
+ fail(http.StatusBadRequest, "Invalid form submission.")
+ return
+ }
+ current := r.PostFormValue("current_password")
+ password := r.PostFormValue("new_password")
+ confirm := r.PostFormValue("new_password_confirm")
+ if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(current)); err != nil {
+ fail(http.StatusUnauthorized, "Current password is incorrect.")
+ return
+ }
+ if password != confirm {
+ fail(http.StatusBadRequest, "New passwords do not match.")
+ return
+ }
+ if err := validate.AdminPassword(password); err != nil {
+ fail(http.StatusBadRequest, err.Error())
+ return
+ }
+ hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
+ if err != nil {
+ logf("panel: account: hashing password failed: %v", err)
+ fail(http.StatusInternalServerError, "Internal error. Please try again.")
+ return
+ }
+ if err := h.store.UpdateUser(u.ID, u.Username, string(hash), u.Email); err != nil {
+ logf("panel: account: update password of user %d: %v", u.ID, err)
+ fail(http.StatusInternalServerError, "Could not save the password. Please check the logs and try again.")
+ return
+ }
+ if token, ok := h.auth.SessionToken(r); ok {
+ h.auth.DestroyOtherSessions(token)
+ }
+ 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)
+}
+
+// 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)
+ }
+}
diff --git a/internal/web/handlers/handlers_account_test.go b/internal/web/handlers/handlers_account_test.go
new file mode 100644
index 0000000..2cd4a6d
--- /dev/null
+++ b/internal/web/handlers/handlers_account_test.go
@@ -0,0 +1,172 @@
+package handlers
+
+import (
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ "github.com/mixeme/selfpost/internal/store"
+ "golang.org/x/crypto/bcrypt"
+)
+
+// 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) {
+ 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"`} {
+ if strings.Contains(out, gone) {
+ t.Errorf("account page shows %q (instance setting, or hosted reports with ingest off)", 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.
+func TestAccountProfile(t *testing.T) {
+ h, _ := settingsServer(t)
+ before, _ := h.store.GetUser(globalPrincipal.ID)
+
+ rec := postFormAs(h.HandleAccountProfile, globalPrincipal, "/account/profile", nil,
+ url.Values{"username": {"operator"}, "email": {"mix@example.org"}})
+ if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/account?done=profile" {
+ t.Fatalf("POST /account/profile = %d to %q:\n%s", rec.Code, rec.Header().Get("Location"), rec.Body.String())
+ }
+ u, _ := h.store.GetUser(globalPrincipal.ID)
+ 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)
+ }
+
+ for _, bad := range []url.Values{
+ {"username": {"operator"}, "email": {"not-an-address"}},
+ {"username": {"x y"}, "email": {""}},
+ } {
+ if rec := postFormAs(h.HandleAccountProfile, globalPrincipal, "/account/profile", nil, bad); rec.Code != http.StatusBadRequest {
+ t.Errorf("POST /account/profile %v = %d, want 400", bad, rec.Code)
+ }
+ }
+ // A name that is taken is refused and nothing is half-saved.
+ if _, err := h.store.CreateUser("taken", "h", store.RoleGlobal, store.Reach{}); err != nil {
+ t.Fatal(err)
+ }
+ rec = postFormAs(h.HandleAccountProfile, globalPrincipal, "/account/profile", nil, url.Values{"username": {"taken"}, "email": {"other@example.org"}})
+ if u, _ = h.store.GetUser(globalPrincipal.ID); rec.Code != http.StatusConflict || u.Username != "operator" || u.Email != "mix@example.org" {
+ t.Errorf("a taken username = %d, profile now %q / %q", rec.Code, u.Username, u.Email)
+ }
+}
+
+func TestAccountPassword(t *testing.T) {
+ h, password := settingsServer(t)
+ const next = "another-long-password-2"
+ hashOf := func() string {
+ u, _ := h.store.GetUser(globalPrincipal.ID)
+ return u.PasswordHash
+ }
+ before := hashOf()
+ for name, form := range map[string]url.Values{
+ "wrong current password": {"current_password": {"nope"}, "new_password": {next}, "new_password_confirm": {next}},
+ "mismatch": {"current_password": {password}, "new_password": {next}, "new_password_confirm": {next + "x"}},
+ "too weak": {"current_password": {password}, "new_password": {"short"}, "new_password_confirm": {"short"}},
+ "empty": {"current_password": {password}},
+ } {
+ rec := postFormAs(h.HandleAccountPassword, globalPrincipal, "/account/password", nil, form)
+ if rec.Code == http.StatusSeeOther || hashOf() != before {
+ t.Errorf("%s: %d, password changed: %v", name, rec.Code, hashOf() != before)
+ }
+ }
+ rec := postFormAs(h.HandleAccountPassword, globalPrincipal, "/account/password", nil,
+ url.Values{"current_password": {password}, "new_password": {next}, "new_password_confirm": {next}})
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("POST /account/password = %d:\n%s", rec.Code, rec.Body.String())
+ }
+ if err := bcrypt.CompareHashAndPassword([]byte(hashOf()), []byte(next)); err != nil {
+ t.Errorf("the new password was not stored: %v", err)
+ }
+}
+
+// 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)
+ d, _ := h.store.AddDomain("example.com", "mail")
+ p := domainAdmin(t, h.store, "ops", d.ID)
+ rec := postFormAs(h.HandleAccountProfile, p, "/account/profile", nil, url.Values{"username": {"ops"}, "email": {"ops@example.org"}})
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("POST /account/profile as a domain administrator = %d:\n%s", rec.Code, rec.Body.String())
+ }
+ if u, _ := h.store.GetUser(p.ID); u.Email != "ops@example.org" {
+ t.Errorf("e-mail = %q", u.Email)
+ }
+ // It changed their own row and nobody else's.
+ if admin, _ := h.store.GetUser(globalPrincipal.ID); admin.Email != "" {
+ t.Errorf("another user's e-mail was changed to %q", admin.Email)
+ }
+}
diff --git a/internal/web/handlers/handlers_apps.go b/internal/web/handlers/handlers_apps.go
index ee31772..d05e343 100644
--- a/internal/web/handlers/handlers_apps.go
+++ b/internal/web/handlers/handlers_apps.go
@@ -290,7 +290,7 @@ func (h *Handlers) HandleDomainDNSRecheck(w http.ResponseWriter, r *http.Request
return
}
h.domainDNS(d, record, reportEmail, true)
- http.Redirect(w, r, fmt.Sprintf("/domains/%d?rechecked=1", d.ID), http.StatusSeeOther)
+ http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?rechecked=1", d.ID), http.StatusSeeOther)
}
// intOrBlank renders a non-positive number as an empty string so an unset field
@@ -420,7 +420,7 @@ func (h *Handlers) HandleUpdateAppMode(w http.ResponseWriter, r *http.Request) {
})
return
}
- http.Redirect(w, r, fmt.Sprintf("/domains/%d?modeupdated=1", a.DomainID), http.StatusSeeOther)
+ http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?modeupdated=1", a.DomainID), http.StatusSeeOther)
}
// HandleRegenPassword issues a new password for an application and shows it once
@@ -459,7 +459,7 @@ func (h *Handlers) HandleDeleteApplication(w http.ResponseWriter, r *http.Reques
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
- http.Redirect(w, r, fmt.Sprintf("/domains/%d?appdeleted=1", a.DomainID), http.StatusSeeOther)
+ http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?appdeleted=1", a.DomainID), http.StatusSeeOther)
}
// lookupApplication resolves the {aid} path value to an application, writing a
diff --git a/internal/web/handlers/handlers_backup.go b/internal/web/handlers/handlers_backup.go
index 400e65e..7b42f6c 100644
--- a/internal/web/handlers/handlers_backup.go
+++ b/internal/web/handlers/handlers_backup.go
@@ -284,7 +284,7 @@ func (h *Handlers) HandleImportDomain(w http.ResponseWriter, r *http.Request) {
h.followCreator(d.ID, p.ID)
}
}
- http.Redirect(w, r, fmt.Sprintf("/domains/%d?imported=1", d.ID), http.StatusSeeOther)
+ http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?imported=1", d.ID), http.StatusSeeOther)
}
// secretFilePassword reads the "encrypt this download with a password" controls
diff --git a/internal/web/handlers/handlers_backup_test.go b/internal/web/handlers/handlers_backup_test.go
index 2660cf7..5154895 100644
--- a/internal/web/handlers/handlers_backup_test.go
+++ b/internal/web/handlers/handlers_backup_test.go
@@ -15,7 +15,7 @@ import (
// postForm builds the kind of request the backup and export forms submit.
func postForm(values url.Values) *http.Request {
- r := httptest.NewRequest(http.MethodPost, "/backup", strings.NewReader(values.Encode()))
+ r := httptest.NewRequest(http.MethodPost, "/server/backup", strings.NewReader(values.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return r
}
@@ -107,7 +107,7 @@ func TestDecryptErrorMessage(t *testing.T) {
func TestBackupPageOffersEncryption(t *testing.T) {
h := &Handlers{view: mustView(t), cfg: Config{Version: "test"}}
rec := httptest.NewRecorder()
- h.renderBackupPageWith(rec, httptest.NewRequest(http.MethodGet, "/backup", nil),
+ h.renderBackupPageWith(rec, httptest.NewRequest(http.MethodGet, "/server/backup", nil),
http.StatusOK, "", "The two passwords do not match.")
body := rec.Body.String()
diff --git a/internal/web/handlers/handlers_delegation_test.go b/internal/web/handlers/handlers_delegation_test.go
index e25d9b4..15added 100644
--- a/internal/web/handlers/handlers_delegation_test.go
+++ b/internal/web/handlers/handlers_delegation_test.go
@@ -120,7 +120,7 @@ func newInboundStand(t *testing.T) inboundStand {
}
func (s inboundStand) call(rt inboundRoute, p *auth.Principal, id int64) *httptest.ResponseRecorder {
- return send(rt.handler(s.h), p, rt.method, "/inbound/"+idStr(id)+rt.suffix, map[string]string{"id": idStr(id)}, rt.form)
+ return send(rt.handler(s.h), p, rt.method, "/inbound/domains/"+idStr(id)+rt.suffix, map[string]string{"id": idStr(id)}, rt.form)
}
// Every route of one inbound domain, for every kind of user: 404 for another
@@ -131,7 +131,7 @@ func TestInboundDomainRoutesAreDelegatedPerDomain(t *testing.T) {
s := newInboundStand(t)
const missing = 9999
for _, rt := range inboundDomainRoutes {
- name := rt.method + " /inbound/{id}" + rt.suffix
+ name := rt.method + " /inbound/domains/{id}" + rt.suffix
denied := []struct {
who string
p *auth.Principal
@@ -186,12 +186,12 @@ func TestAddingAndDeletingInboundDomainsStaysGlobal(t *testing.T) {
for _, p := range []*auth.Principal{&s.ownsA, &s.allIn, &s.outboundOnly, nil} {
for _, rt := range inboundGlobalRoutes {
if rec := s.call(rt, p, s.a.ID); rec.Code != http.StatusNotFound {
- t.Errorf("%s /inbound/{id}%s as %+v = %d, want 404", rt.method, rt.suffix, p, rec.Code)
+ t.Errorf("%s /inbound/domains/{id}%s as %+v = %d, want 404", rt.method, rt.suffix, p, rec.Code)
}
}
- rec := send(s.h.HandleAddInbound, p, "POST", "/inbound", nil, url.Values{"name": {"new.example.com"}})
+ rec := send(s.h.HandleAddInbound, p, "POST", "/inbound/domains", nil, url.Values{"name": {"new.example.com"}})
if rec.Code != http.StatusNotFound {
- t.Errorf("POST /inbound as %+v = %d, want 404", p, rec.Code)
+ t.Errorf("POST /inbound/domains as %+v = %d, want 404", p, rec.Code)
}
}
list, err := s.h.store.ListInboundDomains()
@@ -200,11 +200,11 @@ func TestAddingAndDeletingInboundDomainsStaysGlobal(t *testing.T) {
}
// The global role still can.
- if rec := send(s.h.HandleAddInbound, &s.global, "POST", "/inbound", nil, url.Values{"name": {"new.example.com"}}); rec.Code != http.StatusSeeOther {
- t.Errorf("POST /inbound as the global role = %d:\n%s", rec.Code, rec.Body.String())
+ if rec := send(s.h.HandleAddInbound, &s.global, "POST", "/inbound/domains", nil, url.Values{"name": {"new.example.com"}}); rec.Code != http.StatusSeeOther {
+ t.Errorf("POST /inbound/domains as the global role = %d:\n%s", rec.Code, rec.Body.String())
}
if rec := s.call(inboundGlobalRoutes[1], &s.global, s.a.ID); rec.Code != http.StatusSeeOther {
- t.Errorf("POST /inbound/{id}/delete as the global role = %d:\n%s", rec.Code, rec.Body.String())
+ t.Errorf("POST /inbound/domains/{id}/delete as the global role = %d:\n%s", rec.Code, rec.Body.String())
}
}
@@ -213,7 +213,7 @@ func TestAddingAndDeletingInboundDomainsStaysGlobal(t *testing.T) {
func TestInboundListShowsOnlyTheAssignedDomains(t *testing.T) {
s := newInboundStand(t)
list := func(p *auth.Principal) *httptest.ResponseRecorder {
- return send(s.h.HandleInboundList, p, "GET", "/inbound", nil, nil)
+ return send(s.h.HandleInboundList, p, "GET", "/inbound/domains", nil, nil)
}
rec := list(&s.ownsA)
@@ -266,7 +266,7 @@ func TestInboundListWithNothingLeftIs404(t *testing.T) {
t.Fatal(err)
}
p := auth.Principal{ID: u.ID, Username: u.Username, Role: u.Role, InboundDomains: u.InboundDomainIDs}
- if rec := send(s.h.HandleInboundList, &p, "GET", "/inbound", nil, nil); rec.Code != http.StatusNotFound {
+ if rec := send(s.h.HandleInboundList, &p, "GET", "/inbound/domains", nil, nil); rec.Code != http.StatusNotFound {
t.Errorf("list after the only assigned inbound domain was deleted = %d, want 404", rec.Code)
}
}
@@ -297,13 +297,13 @@ func TestOutboundRoutesAnswerAnotherTenant404(t *testing.T) {
handler http.HandlerFunc
pathValues map[string]string
}{
- {"GET", "/domains/{id}", h.HandleDomainDetail, did},
- {"POST", "/domains/{id}/dns-recheck", h.HandleDomainDNSRecheck, did},
- {"POST", "/domains/{id}/applications", h.HandleAddApplication, did},
- {"POST", "/domains/{id}/ratelimit", h.HandleDomainRateLimit, did},
- {"POST", "/domains/{id}/ratelimit/recalc", h.HandleDomainRateLimitRecalc, did},
- {"POST", "/domains/{id}/dmarc", h.HandleDomainDMARC, did},
- {"POST", "/domains/{id}/export", h.HandleExportDomain, did},
+ {"GET", "/outbound/domains/{id}", h.HandleDomainDetail, did},
+ {"POST", "/outbound/domains/{id}/dns-recheck", h.HandleDomainDNSRecheck, did},
+ {"POST", "/outbound/domains/{id}/applications", h.HandleAddApplication, did},
+ {"POST", "/outbound/domains/{id}/settings/ratelimit", h.HandleDomainRateLimit, did},
+ {"POST", "/outbound/domains/{id}/settings/ratelimit/recalc", h.HandleDomainRateLimitRecalc, did},
+ {"POST", "/outbound/domains/{id}/settings/reports", h.HandleDomainDMARC, did},
+ {"POST", "/outbound/domains/{id}/settings/export", h.HandleExportDomain, did},
{"POST", "/applications/{aid}/mode", h.HandleUpdateAppMode, aid},
{"POST", "/applications/{aid}/authips", h.HandleAppAuthIPs, aid},
{"POST", "/applications/{aid}/password", h.HandleRegenPassword, aid},
@@ -350,7 +350,7 @@ func TestUserFormAssignsBothDirections(t *testing.T) {
for k, v := range extra {
form[k] = v
}
- rec := send(h.HandleUserNew, &root, "POST", "/users/new", nil, form)
+ rec := send(h.HandleUserNew, &root, "POST", "/server/users/new", nil, form)
u, _ := st.GetUserByUsername(username)
return rec, u
}
@@ -374,7 +374,7 @@ func TestUserFormAssignsBothDirections(t *testing.T) {
update := func(u store.User, form url.Values) store.User {
t.Helper()
form.Set("username", u.Username)
- rec := send(h.HandleUserEdit, &root, "POST", "/users/"+idStr(u.ID), map[string]string{"uid": idStr(u.ID)}, form)
+ rec := send(h.HandleUserEdit, &root, "POST", "/server/users/"+idStr(u.ID), map[string]string{"uid": idStr(u.ID)}, form)
if rec.Code != http.StatusSeeOther {
t.Fatalf("update %s = %d:\n%s", u.Username, rec.Code, rec.Body.String())
}
@@ -401,7 +401,7 @@ func TestUserFormAssignsBothDirections(t *testing.T) {
h.cfg.InboundEnabled = true
// Emptying both lists is refused and changes nothing.
- rec = send(h.HandleUserEdit, &root, "POST", "/users/"+idStr(u.ID), map[string]string{"uid": idStr(u.ID)},
+ rec = send(h.HandleUserEdit, &root, "POST", "/server/users/"+idStr(u.ID), map[string]string{"uid": idStr(u.ID)},
url.Values{"username": {u.Username}, "role": {"domain"}})
if after, _ := st.GetUser(u.ID); rec.Code != http.StatusBadRequest || after.Reach().Empty() {
t.Errorf("emptying both lists = %d, reach now %+v", rec.Code, after.Reach())
@@ -415,7 +415,7 @@ func TestUserFormAssignsBothDirections(t *testing.T) {
// The form shows both lists with what is ticked.
other, _ := st.GetUserByUsername("inbound-only")
- body := send(h.HandleUserEdit, &root, "GET", "/users/"+idStr(other.ID), map[string]string{"uid": idStr(other.ID)}, nil).Body.String()
+ body := send(h.HandleUserEdit, &root, "GET", "/server/users/"+idStr(other.ID), map[string]string{"uid": idStr(other.ID)}, nil).Body.String()
for _, want := range []string{
`name="all_domains"`, `name="all_inbound_domains"`, "out.example.com",
`name="inbound_domain_ids" value="` + idStr(in.ID) + `" checked`,
diff --git a/internal/web/handlers/handlers_dmarc.go b/internal/web/handlers/handlers_dmarc.go
index 412745b..1ad2f2e 100644
--- a/internal/web/handlers/handlers_dmarc.go
+++ b/internal/web/handlers/handlers_dmarc.go
@@ -72,5 +72,5 @@ func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) {
}
}
h.dns.Forget(d.Name)
- http.Redirect(w, r, fmt.Sprintf("/domains/%d?dmarc=1", d.ID), http.StatusSeeOther)
+ http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?dmarc=1", d.ID), http.StatusSeeOther)
}
diff --git a/internal/web/handlers/handlers_dmarc_test.go b/internal/web/handlers/handlers_dmarc_test.go
index 16013ee..75018d4 100644
--- a/internal/web/handlers/handlers_dmarc_test.go
+++ b/internal/web/handlers/handlers_dmarc_test.go
@@ -47,7 +47,7 @@ func TestDomainDMARCFollowsTheUserWhoChoseInherit(t *testing.T) {
}
save := func(values url.Values) store.Domain {
t.Helper()
- rec := postFormAs(h.HandleDomainDMARC, p, "/domains/1/dmarc", map[string]string{"id": "1"}, values)
+ rec := postFormAs(h.HandleDomainDMARC, p, "/outbound/domains/1/settings/reports", map[string]string{"id": "1"}, values)
if rec.Code != http.StatusSeeOther {
t.Fatalf("POST %v = %d, want 303:\n%s", values, rec.Code, rec.Body.String())
}
@@ -78,40 +78,6 @@ func TestDomainDMARCFollowsTheUserWhoChoseInherit(t *testing.T) {
}
-// Until the Account page replaces the old Settings form, its single address
-// field is the account e-mail and switches the user's DMARC default between
-// "my account e-mail" and "none". Nothing is written to the instance settings.
-func TestSettingsFormMapsToAccountEmailAndDefault(t *testing.T) {
- h, password := settingsServer(t)
- submit := func(email string) store.User {
- t.Helper()
- rec := postFormAs(h.HandleSettings, globalPrincipal, "/settings", nil, url.Values{
- "username": {"admin"}, "current_password": {password}, "dmarc_report_email": {email},
- })
- if rec.Code != http.StatusSeeOther {
- t.Fatalf("POST /settings (%q) = %d, want 303:\n%s", email, rec.Code, rec.Body.String())
- }
- u, err := h.store.GetUser(globalPrincipal.ID)
- if err != nil {
- t.Fatalf("GetUser: %v", err)
- }
- return u
- }
-
- u := submit("mix@example.org")
- if u.Email != "mix@example.org" || u.DMARCDefaultMode != store.DMARCDefaultAccount {
- t.Fatalf("after setting the address: e-mail %q, default %q", u.Email, u.DMARCDefaultMode)
- }
- if v, err := h.store.GetSetting("dmarc_report_email"); err == nil && v != "" {
- t.Fatalf("the address was mirrored into the instance settings: %q", v)
- }
-
- u = submit("")
- if u.Email != "" || u.DMARCDefaultMode != store.DMARCDefaultNone {
- t.Fatalf("after clearing the address: e-mail %q, default %q", u.Email, u.DMARCDefaultMode)
- }
-}
-
// A new domain takes its report address from whoever created it.
func TestFollowCreator(t *testing.T) {
h, _ := settingsServer(t)
diff --git a/internal/web/handlers/handlers_domains.go b/internal/web/handlers/handlers_domains.go
index c26e451..ad98300 100644
--- a/internal/web/handlers/handlers_domains.go
+++ b/internal/web/handlers/handlers_domains.go
@@ -114,7 +114,7 @@ func (h *Handlers) HandleAddDomain(w http.ResponseWriter, r *http.Request) {
return
}
h.followCreator(d.ID, p.ID)
- http.Redirect(w, r, fmt.Sprintf("/domains/%d", d.ID), http.StatusSeeOther)
+ http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d", d.ID), http.StatusSeeOther)
}
// followCreator makes a new domain take its DMARC report address from the
@@ -172,7 +172,7 @@ func (h *Handlers) HandleDeleteDomain(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
- http.Redirect(w, r, "/domains?deleted=1", http.StatusSeeOther)
+ http.Redirect(w, r, "/outbound/domains?deleted=1", http.StatusSeeOther)
}
// HandleReload re-applies both the OpenDKIM configuration and the Postfix
@@ -198,7 +198,7 @@ func (h *Handlers) HandleReload(w http.ResponseWriter, r *http.Request) {
return
}
}
- http.Redirect(w, r, "/status?reloaded=1", http.StatusSeeOther)
+ http.Redirect(w, r, "/overview?reloaded=1", http.StatusSeeOther)
}
func (h *Handlers) lookupDomain(w http.ResponseWriter, r *http.Request) (store.Domain, bool) {
diff --git a/internal/web/handlers/handlers_inbound.go b/internal/web/handlers/handlers_inbound.go
index e3d80d3..6630b32 100644
--- a/internal/web/handlers/handlers_inbound.go
+++ b/internal/web/handlers/handlers_inbound.go
@@ -222,7 +222,7 @@ func (h *Handlers) HandleAddInbound(w http.ResponseWriter, r *http.Request) {
"Could not add the domain. Please check the logs and try again.", raw)
return
}
- http.Redirect(w, r, fmt.Sprintf("/inbound/%d", d.ID), http.StatusSeeOther)
+ http.Redirect(w, r, fmt.Sprintf("/inbound/domains/%d", d.ID), http.StatusSeeOther)
}
func (h *Handlers) HandleInboundDetail(w http.ResponseWriter, r *http.Request) {
@@ -282,7 +282,7 @@ func (h *Handlers) HandleInboundDNSRecheck(w http.ResponseWriter, r *http.Reques
if h.dns != nil && h.cfg.Hostname != "" {
h.dns.InboundMX(d.Name, h.cfg.Hostname, true)
}
- http.Redirect(w, r, fmt.Sprintf("/inbound/%d?rechecked=1", d.ID), http.StatusSeeOther)
+ http.Redirect(w, r, fmt.Sprintf("/inbound/domains/%d?rechecked=1", d.ID), http.StatusSeeOther)
}
func (h *Handlers) HandleInboundTransport(w http.ResponseWriter, r *http.Request) {
@@ -301,7 +301,7 @@ func (h *Handlers) HandleInboundTransport(w http.ResponseWriter, r *http.Request
h.renderInboundDetail(w, r, http.StatusBadRequest, d, inboundDetailView{TransportErr: err.Error()})
return
}
- http.Redirect(w, r, fmt.Sprintf("/inbound/%d?saved=1", d.ID), http.StatusSeeOther)
+ http.Redirect(w, r, fmt.Sprintf("/inbound/domains/%d?saved=1", d.ID), http.StatusSeeOther)
}
func (h *Handlers) HandleInboundRecipients(w http.ResponseWriter, r *http.Request) {
@@ -319,7 +319,7 @@ func (h *Handlers) HandleInboundRecipients(w http.ResponseWriter, r *http.Reques
h.renderInboundDetail(w, r, http.StatusBadRequest, d, inboundDetailView{RecipientErr: err.Error()})
return
}
- http.Redirect(w, r, fmt.Sprintf("/inbound/%d?recipients=1", d.ID), http.StatusSeeOther)
+ http.Redirect(w, r, fmt.Sprintf("/inbound/domains/%d?recipients=1", d.ID), http.StatusSeeOther)
}
func (h *Handlers) HandleInboundDeleteConfirm(w http.ResponseWriter, r *http.Request) {
@@ -358,5 +358,5 @@ func (h *Handlers) HandleInboundDelete(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
- http.Redirect(w, r, "/inbound?deleted=1", http.StatusSeeOther)
+ http.Redirect(w, r, "/inbound/domains?deleted=1", http.StatusSeeOther)
}
diff --git a/internal/web/handlers/handlers_inbound_test.go b/internal/web/handlers/handlers_inbound_test.go
index abf95bb..20f4615 100644
--- a/internal/web/handlers/handlers_inbound_test.go
+++ b/internal/web/handlers/handlers_inbound_test.go
@@ -51,16 +51,16 @@ func inboundCall(h *Handlers, method, target string, form url.Values, p auth.Pri
req = httptest.NewRequest(method, target, nil)
}
req = auth.RequestWithPrincipal(req, p)
- if rest, ok := strings.CutPrefix(req.URL.Path, "/inbound/"); ok {
+ if rest, ok := strings.CutPrefix(req.URL.Path, "/inbound/domains/"); ok {
id, _, _ := strings.Cut(rest, "/")
if id != "" && id != "delete" {
req.SetPathValue("id", id)
}
}
switch {
- case method == http.MethodGet && target == "/inbound":
+ case method == http.MethodGet && target == "/inbound/domains":
h.HandleInboundList(rec, req)
- case method == http.MethodPost && target == "/inbound":
+ case method == http.MethodPost && target == "/inbound/domains":
h.HandleAddInbound(rec, req)
case strings.HasSuffix(target, "/delete") && method == http.MethodGet:
h.HandleInboundDeleteConfirm(rec, req)
@@ -78,7 +78,7 @@ func inboundCall(h *Handlers, method, target string, form url.Values, p auth.Pri
func TestInboundListAndAdd(t *testing.T) {
h, _ := inboundHandlers(t)
- rec := inboundCall(h, http.MethodGet, "/inbound", nil, globalPrincipal)
+ rec := inboundCall(h, http.MethodGet, "/inbound/domains", nil, globalPrincipal)
if rec.Code != http.StatusOK {
t.Fatalf("list = %d\n%s", rec.Code, rec.Body.String())
}
@@ -86,12 +86,12 @@ func TestInboundListAndAdd(t *testing.T) {
t.Fatal("list missing add form")
}
- rec = inboundCall(h, http.MethodPost, "/inbound", url.Values{"name": {"lists.example.com"}}, globalPrincipal)
+ rec = inboundCall(h, http.MethodPost, "/inbound/domains", url.Values{"name": {"lists.example.com"}}, globalPrincipal)
if rec.Code != http.StatusSeeOther {
t.Fatalf("add = %d %s", rec.Code, rec.Body.String())
}
- rec = inboundCall(h, http.MethodGet, "/inbound", nil, globalPrincipal)
+ rec = inboundCall(h, http.MethodGet, "/inbound/domains", nil, globalPrincipal)
if !strings.Contains(rec.Body.String(), "lists.example.com") {
t.Fatalf("list missing domain:\n%s", rec.Body.String())
}
@@ -100,7 +100,7 @@ func TestInboundListAndAdd(t *testing.T) {
func TestInboundDisabledIs404(t *testing.T) {
h, _ := inboundHandlers(t)
h.cfg.InboundEnabled = false
- rec := inboundCall(h, http.MethodGet, "/inbound", nil, globalPrincipal)
+ rec := inboundCall(h, http.MethodGet, "/inbound/domains", nil, globalPrincipal)
if rec.Code != http.StatusNotFound {
t.Fatalf("disabled inbound = %d, want 404", rec.Code)
}
@@ -114,14 +114,14 @@ func TestInboundTransportAndRecipients(t *testing.T) {
}
id := itoa(d.ID)
- rec := inboundCall(h, http.MethodPost, "/inbound/"+id+"/upstream", url.Values{
+ rec := inboundCall(h, http.MethodPost, "/inbound/domains/"+id+"/upstream", url.Values{
"host": {"10.0.0.8"}, "port": {"25"}, "tls_mode": {"encrypt"},
}, globalPrincipal)
if rec.Code != http.StatusSeeOther {
t.Fatalf("upstream = %d %s", rec.Code, rec.Body.String())
}
- rec = inboundCall(h, http.MethodPost, "/inbound/"+id+"/recipients", url.Values{
+ rec = inboundCall(h, http.MethodPost, "/inbound/domains/"+id+"/recipients", url.Values{
"recipient_mode": {"list"},
"addresses": {"staff@lists.example.com\nabuse@other.com"},
}, globalPrincipal)
@@ -129,7 +129,7 @@ func TestInboundTransportAndRecipients(t *testing.T) {
t.Fatalf("foreign recipient = %d, want 400", rec.Code)
}
- rec = inboundCall(h, http.MethodPost, "/inbound/"+id+"/recipients", url.Values{
+ rec = inboundCall(h, http.MethodPost, "/inbound/domains/"+id+"/recipients", url.Values{
"recipient_mode": {"list"},
"addresses": {"staff@lists.example.com"},
}, globalPrincipal)
@@ -137,7 +137,7 @@ func TestInboundTransportAndRecipients(t *testing.T) {
t.Fatalf("recipients = %d %s", rec.Code, rec.Body.String())
}
- rec = inboundCall(h, http.MethodGet, "/inbound/"+id, nil, globalPrincipal)
+ rec = inboundCall(h, http.MethodGet, "/inbound/domains/"+id, nil, globalPrincipal)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "10.0.0.8") {
t.Fatalf("detail =\n%s", rec.Body.String())
}
@@ -150,7 +150,7 @@ func TestInboundDelete(t *testing.T) {
t.Fatal(err)
}
id := itoa(d.ID)
- rec := inboundCall(h, http.MethodPost, "/inbound/"+id+"/delete", nil, globalPrincipal)
+ rec := inboundCall(h, http.MethodPost, "/inbound/domains/"+id+"/delete", nil, globalPrincipal)
if rec.Code != http.StatusSeeOther {
t.Fatalf("delete = %d %s", rec.Code, rec.Body.String())
}
diff --git a/internal/web/handlers/handlers_monitor.go b/internal/web/handlers/handlers_monitor.go
index 0ae73df..258ff72 100644
--- a/internal/web/handlers/handlers_monitor.go
+++ b/internal/web/handlers/handlers_monitor.go
@@ -295,9 +295,9 @@ func deliveriesBackURL(r *http.Request) string {
}
}
if len(back) == 0 {
- return "/deliveries"
+ return "/outbound/log"
}
- return "/deliveries?" + back.Encode()
+ return "/outbound/log?" + back.Encode()
}
// sendLogData reads the domain/app filters and page number off the query
diff --git a/internal/web/handlers/handlers_monitor_test.go b/internal/web/handlers/handlers_monitor_test.go
index 091e777..fbe8e7e 100644
--- a/internal/web/handlers/handlers_monitor_test.go
+++ b/internal/web/handlers/handlers_monitor_test.go
@@ -37,11 +37,11 @@ func TestReadLogTailMissingFileIsNotAnError(t *testing.T) {
func TestDeliveryLogShowsOnlyTheIdentifyingColumns(t *testing.T) {
h, row := serverWithDelivery(t)
- out := getBody(t, h.HandleDeliveries, "/deliveries")
+ out := getBody(t, h.HandleDeliveries, "/outbound/log")
for _, want := range []string{
row.CreatedAt.Format("2006-01-02 15:04:05"),
"noreply@bs.example.ru", "public@example.ru",
- "Проверка", ">sent<", `href="/deliveries/` + itoa(row.ID),
+ "Проверка", ">sent<", `href="/outbound/log/` + itoa(row.ID),
} {
if !strings.Contains(out, want) {
t.Errorf("delivery log is missing %q:\n%s", want, out)
@@ -63,8 +63,8 @@ func TestDeliveryLogDecodesStoredEncodedSubjects(t *testing.T) {
h, _ := serverWithDelivery(t)
for name, out := range map[string]string{
- "log": getBody(t, h.HandleDeliveries, "/deliveries"),
- "rows": getBody(t, h.HandleDeliveriesRows, "/deliveries/rows"),
+ "log": getBody(t, h.HandleDeliveries, "/outbound/log"),
+ "rows": getBody(t, h.HandleDeliveriesRows, "/outbound/log/fragment"),
} {
if strings.Contains(out, "=?utf-8?Q?") {
t.Errorf("%s shows the subject's MIME encoding instead of its text:\n%s", name, out)
@@ -80,11 +80,11 @@ func TestDeliveryLogDecodesStoredEncodedSubjects(t *testing.T) {
func TestDeliveryPageShowsWhatTheLogOmits(t *testing.T) {
h, row := serverWithDelivery(t)
- out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID)+"?domain=bs.example.ru&p=2")
+ out := getBody(t, h.HandleDelivery, "/outbound/log/"+itoa(row.ID)+"?domain=bs.example.ru&p=2")
for _, want := range []string{
"bs.example.ru", "Queuer3C", "4A1B2C3D", "Проверка",
"noreply@bs.example.ru", "public@example.ru", "sent",
- `href="/deliveries?domain=bs.example.ru&p=2"`,
+ `href="/outbound/log?domain=bs.example.ru&p=2"`,
} {
if !strings.Contains(out, want) {
t.Errorf("delivery page is missing %q:\n%s", want, out)
@@ -101,7 +101,7 @@ func TestDeliveryPageShowsWhatTheLogOmits(t *testing.T) {
func TestDeliveryPageTellsTheMessagesHistory(t *testing.T) {
h, row := serverWithDelivery(t)
- out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID))
+ out := getBody(t, h.HandleDelivery, "/outbound/log/"+itoa(row.ID))
for _, want := range []string{
"Accepted and queued", "Delivered",
row.CreatedAt.Format("2006-01-02 15:04:05"),
@@ -137,7 +137,7 @@ func TestDeliveryPageMarksAQueuedMessageAsStillWaiting(t *testing.T) {
t.Fatalf("query: %v (%d rows)", err, len(rows))
}
- out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(rows[0].ID))
+ out := getBody(t, h.HandleDelivery, "/outbound/log/"+itoa(rows[0].ID))
for _, want := range []string{"Waiting for a delivery report", "pending", "not yet"} {
if !strings.Contains(out, want) {
t.Errorf("delivery page does not mark the message as still waiting (%q):\n%s", want, out)
@@ -157,7 +157,7 @@ func TestDeliveryPageShowsThisMessagesLogLines(t *testing.T) {
"2026-08-03T05:16:03.884210+00:00 host postfix/smtp[26]: 4A1B2C3D: to=, dsn=2.0.0, status=sent (250 OK)",
)
- out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID))
+ out := getBody(t, h.HandleDelivery, "/outbound/log/"+itoa(row.ID))
for _, want := range []string{
"| Time | ", "Message | ",
// The stamp is split off into its own cell, without the microseconds
@@ -181,7 +181,7 @@ func TestDeliveryPageKeepsAnUnstampedLogLineWhole(t *testing.T) {
h, row := serverWithDelivery(t)
h.cfg.MailLogPath = writeMailLog(t, "host postfix/smtp[26]: 4A1B2C3D: to=, status=sent (250 OK)")
- out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID))
+ out := getBody(t, h.HandleDelivery, "/outbound/log/"+itoa(row.ID))
if !strings.Contains(out, "host postfix/smtp[26]: 4A1B2C3D: to=<public@example.ru>, status=sent (250 OK)") {
t.Errorf("an unstamped log line did not survive the split into columns:\n%s", out)
}
@@ -197,7 +197,7 @@ func TestDeliveryPageExplainsAnEmptyDeliveryLog(t *testing.T) {
}
h.cfg.MailLogPath = filepath.Join(t.TempDir(), "mail.log") // never created
- out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID))
+ out := getBody(t, h.HandleDelivery, "/outbound/log/"+itoa(row.ID))
if !strings.Contains(out, "rotated away") {
t.Errorf("delivery page does not explain the empty delivery log:\n%s", out)
}
@@ -214,10 +214,10 @@ func TestDeliveryPageExplainsAnEmptyDeliveryLog(t *testing.T) {
func TestDeliveryPageNotFound(t *testing.T) {
h, _ := serverWithDelivery(t)
- for _, path := range []string{"/deliveries/999999", "/deliveries/abc", "/deliveries/0"} {
+ for _, path := range []string{"/outbound/log/999999", "/outbound/log/abc", "/outbound/log/0"} {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, path, nil)
- req.SetPathValue("id", strings.TrimPrefix(path, "/deliveries/"))
+ req.SetPathValue("id", strings.TrimPrefix(path, "/outbound/log/"))
h.HandleDelivery(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("GET %s = %d, want 404", path, rec.Code)
@@ -266,7 +266,7 @@ func TestSendLogScopedToAssignedDomains(t *testing.T) {
"page": h.HandleDeliveries,
"fragment": h.HandleDeliveriesRows,
} {
- out := getBodyAs(t, handler, "/deliveries", p)
+ out := getBodyAs(t, handler, "/outbound/log", p)
for _, want := range tc.want {
if !strings.Contains(out, want) {
t.Errorf("%s (%s): missing %q:\n%s", name, view, want, out)
@@ -290,9 +290,9 @@ func TestSendLogIgnoresForgedFilters(t *testing.T) {
p := domainAdmin(t, h.store, "forged-filter", domains["first.example.ru"].ID)
for _, target := range []string{
- "/deliveries?domain=second.example.ru",
- "/deliveries?app=second-app",
- "/deliveries?domain=second.example.ru&app=second-app",
+ "/outbound/log?domain=second.example.ru",
+ "/outbound/log?app=second-app",
+ "/outbound/log?domain=second.example.ru&app=second-app",
} {
out := getBodyAs(t, h.HandleDeliveries, target, p)
if strings.Contains(out, "Second message") {
@@ -314,7 +314,7 @@ func TestDeliveryPageForeignDomainNotFound(t *testing.T) {
}
rec := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/deliveries/"+itoa(rows[0].ID), nil)
+ req := httptest.NewRequest(http.MethodGet, "/outbound/log/"+itoa(rows[0].ID), nil)
req.SetPathValue("id", itoa(rows[0].ID))
req = auth.RequestWithPrincipal(req, domainAdmin(t, h.store, "foreign-detail", domains["first.example.ru"].ID))
h.HandleDelivery(rec, req)
@@ -443,7 +443,7 @@ func getBodyAs(t *testing.T, h http.HandlerFunc, target string, p auth.Principal
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, target, nil)
req = auth.RequestWithPrincipal(req, p)
- if rest, ok := strings.CutPrefix(req.URL.Path, "/deliveries/"); ok && rest != "rows" {
+ if rest, ok := strings.CutPrefix(req.URL.Path, "/outbound/log/"); ok && rest != "rows" {
req.SetPathValue("id", rest)
}
h(rec, req)
@@ -483,7 +483,7 @@ func fixtureRetryPolicy() postfix.RetryPolicy {
func TestMailQueueShowsRetryPolicyCard(t *testing.T) {
h := &Handlers{view: mustView(t), cfg: Config{Version: "test", RetryPolicy: fixtureRetryPolicy()}}
- out := getBody(t, h.HandleMailQueue, "/mail-queue")
+ out := getBody(t, h.HandleMailQueue, "/outbound/queue")
for _, want := range []string{
"How delivery retries work",
"id=\"retry-policy\"",
@@ -506,7 +506,7 @@ func TestMailQueueShowsRetryPolicyCard(t *testing.T) {
func TestMailQueueBodyOmitsRetryPolicyCard(t *testing.T) {
h := &Handlers{view: mustView(t), cfg: Config{RetryPolicy: fixtureRetryPolicy()}}
- out := getBody(t, h.HandleMailQueueBody, "/mail-queue/body")
+ out := getBody(t, h.HandleMailQueueBody, "/outbound/queue/fragment")
if strings.Contains(out, "How delivery retries work") || strings.Contains(out, "10 minutes") {
t.Errorf("HTMX fragment includes the retry card:\n%s", out)
}
@@ -515,7 +515,7 @@ func TestMailQueueBodyOmitsRetryPolicyCard(t *testing.T) {
func TestMailQueueNotesCompiledInFallback(t *testing.T) {
h := &Handlers{view: mustView(t), cfg: Config{RetryPolicy: postfix.DefaultRetryPolicy()}}
- out := getBody(t, h.HandleMailQueue, "/mail-queue")
+ out := getBody(t, h.HandleMailQueue, "/outbound/queue")
if !strings.Contains(out, "compiled-in defaults") {
t.Errorf("fallback note missing:\n%s", out)
}
@@ -528,7 +528,7 @@ func TestDeliveryPageDeferredUsesRetryPolicy(t *testing.T) {
t.Fatalf("update status: %v", err)
}
- out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID))
+ out := getBody(t, h.HandleDelivery, "/outbound/log/"+itoa(row.ID))
for _, want := range []string{
"first after 10 minutes",
"up to about 1 hour 7 minutes",
@@ -547,7 +547,7 @@ func TestDeliveryPageBouncedUsesRetryPolicy(t *testing.T) {
t.Fatalf("update status: %v", err)
}
- out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID))
+ out := getBody(t, h.HandleDelivery, "/outbound/log/"+itoa(row.ID))
if !strings.Contains(out, "gave up after 2 days in the queue") {
t.Errorf("bounced history does not use the fixture lifetime:\n%s", out)
}
diff --git a/internal/web/handlers/handlers_ratelimit.go b/internal/web/handlers/handlers_ratelimit.go
index 651804e..08c2b70 100644
--- a/internal/web/handlers/handlers_ratelimit.go
+++ b/internal/web/handlers/handlers_ratelimit.go
@@ -162,7 +162,7 @@ func (h *Handlers) HandleDomainRateLimit(w http.ResponseWriter, r *http.Request)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
- http.Redirect(w, r, fmt.Sprintf("/domains/%d?ratelimit=1", d.ID), http.StatusSeeOther)
+ http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?ratelimit=1", d.ID), http.StatusSeeOther)
}
func (h *Handlers) HandleAppRateLimit(w http.ResponseWriter, r *http.Request) {
@@ -188,7 +188,7 @@ func (h *Handlers) HandleAppRateLimit(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
- http.Redirect(w, r, fmt.Sprintf("/domains/%d?ratelimit=1", a.DomainID), http.StatusSeeOther)
+ http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?ratelimit=1", a.DomainID), http.StatusSeeOther)
}
func (h *Handlers) HandleAppAuthIPs(w http.ResponseWriter, r *http.Request) {
@@ -214,7 +214,7 @@ func (h *Handlers) HandleAppAuthIPs(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
- http.Redirect(w, r, fmt.Sprintf("/domains/%d?authips=1", a.DomainID), http.StatusSeeOther)
+ http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?authips=1", a.DomainID), http.StatusSeeOther)
}
func (h *Handlers) HandleDomainRateLimitRecalc(w http.ResponseWriter, r *http.Request) {
@@ -230,7 +230,7 @@ func (h *Handlers) HandleDomainRateLimitRecalc(w http.ResponseWriter, r *http.Re
})
return
}
- http.Redirect(w, r, fmt.Sprintf("/domains/%d?recalculated=1", d.ID), http.StatusSeeOther)
+ http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?recalculated=1", d.ID), http.StatusSeeOther)
}
func (h *Handlers) HandleAppRateLimitRecalc(w http.ResponseWriter, r *http.Request) {
@@ -246,7 +246,7 @@ func (h *Handlers) HandleAppRateLimitRecalc(w http.ResponseWriter, r *http.Reque
})
return
}
- http.Redirect(w, r, fmt.Sprintf("/domains/%d?recalculated=1", a.DomainID), http.StatusSeeOther)
+ http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?recalculated=1", a.DomainID), http.StatusSeeOther)
}
func (h *Handlers) recalcRateLimit(scope string, refID int64) error {
diff --git a/internal/web/handlers/handlers_settings.go b/internal/web/handlers/handlers_settings.go
index b8bb25c..25e1f55 100644
--- a/internal/web/handlers/handlers_settings.go
+++ b/internal/web/handlers/handlers_settings.go
@@ -1,283 +1,72 @@
package handlers
import (
- "context"
- "errors"
"net/http"
"strconv"
"strings"
- "time"
- "github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/store"
- "github.com/mixeme/selfpost/internal/web/auth"
- "github.com/mixeme/selfpost/internal/web/validate"
- "golang.org/x/crypto/bcrypt"
)
-// HandleSettings serves the signed-in user's panel settings.
-func (h *Handlers) HandleSettings(w http.ResponseWriter, r *http.Request) {
+// Server › Settings holds what is true of the whole instance, whoever is
+// signed in: log retention and the read-only view of the rate limits. A
+// user's own name, e-mail, password and DMARC default are on Account
+// (handlers_account.go). Before placing a setting here, ask whose it is.
+
+// HandleServerSettings shows and saves the instance settings (global role).
+func (h *Handlers) HandleServerSettings(w http.ResponseWriter, r *http.Request) {
+ if _, ok := h.requireGlobal(w, r); !ok {
+ return
+ }
switch r.Method {
case http.MethodGet:
- p, ok := h.principal(r)
- if !ok {
- http.Error(w, "internal error", http.StatusInternalServerError)
- return
- }
- u, err := h.store.GetUser(p.ID)
- if err != nil {
- logf("panel: settings: get user failed: %v", err)
- http.Error(w, "internal error", http.StatusInternalServerError)
- return
- }
- h.renderSettings(w, r, http.StatusOK, "", u.Username, u.Email, h.sendLogRetentionDays(), p.IsGlobal())
+ h.renderServerSettings(w, r, http.StatusOK, "", h.sendLogRetentionDays())
case http.MethodPost:
- h.submitSettings(w, r)
+ h.submitServerSettings(w, r)
default:
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
-func (h *Handlers) renderSettings(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername, formDMARCEmail string, formSendLogRetentionDays int, showDMARC bool) {
- var reportAuth dnscheck.Result
- if showDMARC && formDMARCEmail != "" {
- if hub := dnscheck.EmailDomain(formDMARCEmail); hub != "" {
- ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
- reportAuth = h.dns.ReportAuth(ctx, hub)
- cancel()
- }
- }
+func (h *Handlers) renderServerSettings(w http.ResponseWriter, r *http.Request, status int, formErr string, formRetention int) {
data := h.pageBase(r)
data["Title"] = "SelfPost — settings"
data["Active"] = "settings"
- data["FormUsername"] = formUsername
- data["FormDMARCEmail"] = formDMARCEmail
- data["FormSendLogRetentionDays"] = formSendLogRetentionDays
- data["ShowDMARC"] = showDMARC
- data["ReportAuthName"] = dnscheck.ReportAuthRecordName(dnscheck.EmailDomain(formDMARCEmail))
- data["ReportAuthExample"] = dnscheck.ReportAuthExample()
- data["ReportAuthDNS"] = reportAuth
- data["ReportAuthHub"] = dnscheck.EmailDomain(formDMARCEmail)
+ data["AccountPage"] = false
+ data["FormSendLogRetentionDays"] = formRetention
data["Error"] = formErr
- data["Flash"] = settingsFlash(r)
+ if r.URL.Query().Has("saved") {
+ data["Flash"] = "Settings saved."
+ }
data["L1Messages"] = h.l1Messages()
data["L1Window"] = h.l1Window()
- data["DMARCIngestEnabled"] = h.cfg.DMARCEnabled
- if h.dmarc != nil && h.cfg.DMARCEnabled {
- data["HostedReportAddress"] = h.dmarc.DefaultHostedSuggestion()
- }
h.view.Render(w, status, "settings", data)
}
-func settingsFlash(r *http.Request) string {
- q := r.URL.Query()
- var parts []string
- if q.Has("u") {
- parts = append(parts, "username")
- }
- if q.Has("p") {
- parts = append(parts, "password")
- }
- if q.Has("e") {
- parts = append(parts, "DMARC report address")
- }
- if q.Has("r") {
- parts = append(parts, "send log retention")
- }
- if len(parts) == 0 {
- return ""
- }
- msg := strings.Join(parts, ", ") + " updated."
- msg = strings.ToUpper(msg[:1]) + msg[1:]
- if q.Has("p") {
- msg += " Any other signed-in sessions were signed out."
- }
- return msg
-}
-
-func (h *Handlers) submitSettings(w http.ResponseWriter, r *http.Request) {
- p, ok := h.principal(r)
- if !ok {
- http.Error(w, "internal error", http.StatusInternalServerError)
- return
- }
- currentRetention := h.sendLogRetentionDays()
-
- if !h.auth.AllowLoginAttempt(r) {
- h.renderSettings(w, r, http.StatusTooManyRequests,
- "Too many attempts. Please wait and try again.", auth.CurrentUser(r), "", currentRetention, p.IsGlobal())
- return
- }
+func (h *Handlers) submitServerSettings(w http.ResponseWriter, r *http.Request) {
+ current := h.sendLogRetentionDays()
if err := r.ParseForm(); err != nil {
- h.renderSettings(w, r, http.StatusBadRequest, "Invalid form submission.", auth.CurrentUser(r), "", currentRetention, p.IsGlobal())
+ h.renderServerSettings(w, r, http.StatusBadRequest, "Invalid form submission.", current)
return
}
-
- user, err := h.store.GetUser(p.ID)
+ days, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue("send_log_retention_days")))
if err != nil {
- logf("panel: settings: get user failed: %v", err)
- http.Error(w, "internal error", http.StatusInternalServerError)
+ h.renderServerSettings(w, r, http.StatusBadRequest, "Send log retention must be a whole number of days.", current)
return
}
-
- username := strings.TrimSpace(r.PostFormValue("username"))
- current := r.PostFormValue("current_password")
- password := r.PostFormValue("new_password")
- confirm := r.PostFormValue("new_password_confirm")
- dmarcEmail := strings.TrimSpace(r.PostFormValue("dmarc_report_email"))
- formRetention := currentRetention
- if !p.IsGlobal() {
- dmarcEmail = user.Email
- } else if raw := strings.TrimSpace(r.PostFormValue("send_log_retention_days")); raw != "" {
- parsed, err := strconv.Atoi(raw)
- if err != nil {
- h.renderSettings(w, r, http.StatusBadRequest, "Send log retention must be a whole number of days.", username, dmarcEmail, currentRetention, true)
- return
- }
- formRetention = parsed
- }
- if username == "" {
- username = user.Username
- }
-
- if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(current)); err != nil {
- h.renderSettings(w, r, http.StatusUnauthorized, "Current password is incorrect.", username, dmarcEmail, formRetention, p.IsGlobal())
+ if err := store.ValidateSendLogRetentionDays(days); err != nil {
+ h.renderServerSettings(w, r, http.StatusBadRequest, err.Error(), days)
return
}
-
- renaming := username != user.Username
- if renaming {
- if err := validate.Username(username); err != nil {
- h.renderSettings(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, formRetention, p.IsGlobal())
- return
- }
- }
-
- if p.IsGlobal() {
- if err := validate.Email(dmarcEmail); err != nil {
- h.renderSettings(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, formRetention, true)
- return
- }
- }
-
- emailChanging := p.IsGlobal() && dmarcEmail != user.Email
-
- retentionChanging := false
- if p.IsGlobal() && formRetention != currentRetention {
- if err := store.ValidateSendLogRetentionDays(formRetention); err != nil {
- h.renderSettings(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, formRetention, true)
- return
- }
- retentionChanging = true
- }
-
- repassword := password != "" || confirm != ""
- if repassword {
- if password != confirm {
- h.renderSettings(w, r, http.StatusBadRequest, "New passwords do not match.", username, dmarcEmail, formRetention, p.IsGlobal())
- return
- }
- if err := validate.AdminPassword(password); err != nil {
- h.renderSettings(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, formRetention, p.IsGlobal())
- return
- }
- }
- if !renaming && !repassword && !emailChanging && !retentionChanging {
- h.renderSettings(w, r, http.StatusBadRequest,
- "Nothing to change: enter a new username, password, DMARC report address, or send log retention.", username, dmarcEmail, formRetention, p.IsGlobal())
+ if err := h.store.SetSendLogRetentionDays(days); err != nil {
+ logf("panel: settings: set send-log retention failed: %v", err)
+ h.renderServerSettings(w, r, http.StatusInternalServerError,
+ "Could not save send log retention. Please check the logs and try again.", days)
return
}
-
- hash := user.PasswordHash
- if repassword {
- newHash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
- if err != nil {
- logf("panel: settings: hashing password failed: %v", err)
- h.renderSettings(w, r, http.StatusInternalServerError,
- "Internal error. Please try again.", username, dmarcEmail, formRetention, p.IsGlobal())
- return
- }
- hash = string(newHash)
- }
-
- if renaming || repassword || emailChanging {
- if err := h.store.UpdateUser(user.ID, username, hash, dmarcEmail); err != nil {
- logf("panel: settings: update user failed: %v", err)
- msg := "Could not save the changes. Please check the logs and try again."
- if errors.Is(err, store.ErrUserNotFound) {
- msg = "There is no user account to update."
- }
- if errors.Is(err, store.ErrUserExists) {
- msg = "That username is already in use."
- h.renderSettings(w, r, http.StatusConflict, msg, username, dmarcEmail, formRetention, p.IsGlobal())
- return
- }
- h.renderSettings(w, r, http.StatusInternalServerError, msg, username, dmarcEmail, formRetention, p.IsGlobal())
- return
- }
- }
-
- // Until the Account page replaces this form (stage 2), its one "default
- // report address" field stands for both halves of the 2.0 model: it is the
- // account e-mail, and the user's DMARC default is "my account e-mail" while
- // it is filled and "none" once it is cleared.
- if emailChanging {
- mode := store.DMARCDefaultNone
- if dmarcEmail != "" {
- mode = store.DMARCDefaultAccount
- }
- if err := h.store.SetDMARCDefault(user.ID, mode, ""); err != nil {
- logf("panel: settings: set dmarc default failed: %v", err)
- h.renderSettings(w, r, http.StatusInternalServerError,
- "Could not save the changes. Please check the logs and try again.", username, dmarcEmail, formRetention, p.IsGlobal())
- return
- }
- }
-
- if retentionChanging {
- if err := h.store.SetSendLogRetentionDays(formRetention); err != nil {
- logf("panel: settings: set send-log retention failed: %v", err)
- h.renderSettings(w, r, http.StatusInternalServerError,
- "Could not save send log retention. Please check the logs and try again.", username, dmarcEmail, formRetention, true)
- return
- }
- }
-
- if emailChanging && h.dmarc != nil && h.dmarc.Enabled() {
- if err := h.dmarc.Resync(); err != nil {
- logf("panel: settings: dmarc resync: %v", err)
- }
- }
-
- if token, ok := h.auth.SessionToken(r); ok {
- if renaming {
- h.auth.RenameSession(token, username)
- }
- if repassword {
- h.auth.DestroyOtherSessions(token)
- }
- }
-
- logf("panel: user %d settings updated (username: %t, password: %t, dmarc email: %t, retention: %t)", user.ID, renaming, repassword, emailChanging, retentionChanging)
- http.Redirect(w, r, "/settings?"+updatedQuery(renaming, repassword, emailChanging, retentionChanging), http.StatusSeeOther)
-}
-
-func updatedQuery(renamed, repassword, emailChanged, retentionChanged bool) string {
- var params []string
- if renamed {
- params = append(params, "u=1")
- }
- if repassword {
- params = append(params, "p=1")
- }
- if emailChanged {
- params = append(params, "e=1")
- }
- if retentionChanged {
- params = append(params, "r=1")
- }
- return strings.Join(params, "&")
+ logf("panel: instance settings updated (send-log retention: %d days)", days)
+ http.Redirect(w, r, "/server/settings?saved=1", http.StatusSeeOther)
}
// sendLogRetentionDays returns the effective delivery-journal retention window.
diff --git a/internal/web/handlers/handlers_settings_test.go b/internal/web/handlers/handlers_settings_test.go
index 93589f3..fbfc845 100644
--- a/internal/web/handlers/handlers_settings_test.go
+++ b/internal/web/handlers/handlers_settings_test.go
@@ -2,7 +2,6 @@ package handlers
import (
"net/http"
- "net/http/httptest"
"net/url"
"path/filepath"
"strings"
@@ -20,37 +19,35 @@ func TestSettingsPageShowsSendLogRetention(t *testing.T) {
t.Fatalf("SetSendLogRetentionDays: %v", err)
}
- out := getBody(t, h.HandleSettings, "/settings")
+ out := getBody(t, h.HandleServerSettings, "/server/settings")
for _, want := range []string{
`id="deliveries-retention"`,
`name="send_log_retention_days"`,
`value="45"`,
"Send log retention",
+ `action="/server/settings"`,
+ `id="rate-limits"`,
} {
if !strings.Contains(out, want) {
t.Errorf("settings page missing %q:\n%s", want, out)
}
}
+ // What belongs to a user is not on the instance's page.
+ for _, gone := range []string{`name="current_password"`, `name="username"`, `name="dmarc_default"`} {
+ if strings.Contains(out, gone) {
+ t.Errorf("the instance settings page still carries the user's %s", gone)
+ }
+ }
}
+// The instance settings ask for no password: they are the server's, and the
+// page is behind the global role.
func TestSubmitSettingsSavesSendLogRetention(t *testing.T) {
- h, password := settingsServer(t)
-
- values := url.Values{
- "username": {"admin"},
- "current_password": {password},
- "send_log_retention_days": {"120"},
+ h, _ := settingsServer(t)
+ rec := postFormAs(h.HandleServerSettings, globalPrincipal, "/server/settings", nil, url.Values{"send_log_retention_days": {"120"}})
+ if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/server/settings?saved=1" {
+ t.Fatalf("POST /server/settings = %d to %q, want 303:\n%s", rec.Code, rec.Header().Get("Location"), rec.Body.String())
}
- req := httptest.NewRequest(http.MethodPost, "/settings", strings.NewReader(values.Encode()))
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- req = auth.RequestWithPrincipal(req, globalPrincipal)
-
- rec := httptest.NewRecorder()
- h.HandleSettings(rec, req)
- if rec.Code != http.StatusSeeOther {
- t.Fatalf("POST /settings = %d, want 303:\n%s", rec.Code, rec.Body.String())
- }
-
got, err := h.store.GetSendLogRetentionDays(90)
if err != nil {
t.Fatalf("GetSendLogRetentionDays: %v", err)
@@ -61,25 +58,35 @@ func TestSubmitSettingsSavesSendLogRetention(t *testing.T) {
}
func TestSubmitSettingsRejectsOutOfRangeRetention(t *testing.T) {
- h, password := settingsServer(t)
-
- values := url.Values{
- "username": {"admin"},
- "current_password": {password},
- "send_log_retention_days": {"3"},
- }
- req := httptest.NewRequest(http.MethodPost, "/settings", strings.NewReader(values.Encode()))
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- req = auth.RequestWithPrincipal(req, globalPrincipal)
-
- rec := httptest.NewRecorder()
- h.HandleSettings(rec, req)
- if rec.Code != http.StatusBadRequest {
- t.Fatalf("POST /settings = %d, want 400:\n%s", rec.Code, rec.Body.String())
+ h, _ := settingsServer(t)
+ for _, bad := range []string{"3", "nine", ""} {
+ rec := postFormAs(h.HandleServerSettings, globalPrincipal, "/server/settings", nil, url.Values{"send_log_retention_days": {bad}})
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("POST /server/settings (%q) = %d, want 400:\n%s", bad, rec.Code, rec.Body.String())
+ }
}
+ rec := postFormAs(h.HandleServerSettings, globalPrincipal, "/server/settings", nil, url.Values{"send_log_retention_days": {"3"}})
if !strings.Contains(rec.Body.String(), "between 7 and 365") {
t.Errorf("expected range error in body:\n%s", rec.Body.String())
}
+ if got, _ := h.store.GetSendLogRetentionDays(90); got != 90 {
+ t.Errorf("a refused value changed the retention to %d", got)
+ }
+}
+
+func TestServerSettingsIsGlobalOnly(t *testing.T) {
+ h, _ := settingsServer(t)
+ d, err := h.store.AddDomain("example.com", "mail")
+ if err != nil {
+ t.Fatal(err)
+ }
+ p := domainAdmin(t, h.store, "ops", d.ID)
+ if rec := postFormAs(h.HandleServerSettings, p, "/server/settings", nil, url.Values{"send_log_retention_days": {"120"}}); rec.Code != http.StatusNotFound {
+ t.Errorf("POST /server/settings as a domain administrator = %d, want 404", rec.Code)
+ }
+ if got, _ := h.store.GetSendLogRetentionDays(90); got != 90 {
+ t.Errorf("a domain administrator changed the retention to %d", got)
+ }
}
func settingsServer(t *testing.T) (*Handlers, string) {
diff --git a/internal/web/handlers/handlers_status.go b/internal/web/handlers/handlers_status.go
index 2518116..247e782 100644
--- a/internal/web/handlers/handlers_status.go
+++ b/internal/web/handlers/handlers_status.go
@@ -33,7 +33,7 @@ func (h *Handlers) HandleStatusRecheck(w http.ResponseWriter, r *http.Request) {
return
}
h.dns.Server(h.cfg.Hostname, true)
- http.Redirect(w, r, "/status?rechecked=1", http.StatusSeeOther)
+ http.Redirect(w, r, "/overview?rechecked=1", http.StatusSeeOther)
}
func (h *Handlers) statusBody() map[string]any {
diff --git a/internal/web/handlers/handlers_users.go b/internal/web/handlers/handlers_users.go
index 8a2c4e6..48c8e27 100644
--- a/internal/web/handlers/handlers_users.go
+++ b/internal/web/handlers/handlers_users.go
@@ -196,7 +196,7 @@ func (h *Handlers) submitUserCreate(w http.ResponseWriter, r *http.Request) {
h.renderUserForm(w, r, http.StatusInternalServerError, 0, userFormView{FormErr: "Could not create user. Please check the logs."})
return
}
- http.Redirect(w, r, "/users?done=created", http.StatusSeeOther)
+ http.Redirect(w, r, "/server/users?done=created", http.StatusSeeOther)
}
func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u store.User) {
@@ -289,7 +289,7 @@ func (h *Handlers) submitUserUpdate(w http.ResponseWriter, r *http.Request, u st
}
}
- http.Redirect(w, r, "/users?done=updated", http.StatusSeeOther)
+ http.Redirect(w, r, "/server/users?done=updated", http.StatusSeeOther)
}
// HandleUserDeleteConfirm shows the cascade warning before a panel user is
@@ -363,7 +363,7 @@ func (h *Handlers) submitUserDelete(w http.ResponseWriter, r *http.Request, u st
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not delete user.", FormUsername: u.Username, FormRole: string(u.Role)})
return
}
- http.Redirect(w, r, "/users?done=deleted", http.StatusSeeOther)
+ http.Redirect(w, r, "/server/users?done=deleted", http.StatusSeeOther)
}
func parseUserID(w http.ResponseWriter, r *http.Request) (int64, bool) {
diff --git a/internal/web/home_test.go b/internal/web/home_test.go
index 786cece..2427152 100644
--- a/internal/web/home_test.go
+++ b/internal/web/home_test.go
@@ -3,6 +3,7 @@ package web
import (
"net/http"
"net/http/httptest"
+ "strings"
"testing"
"github.com/mixeme/selfpost/internal/web/auth"
@@ -15,10 +16,10 @@ func TestRedirectHomeFollowsTheReach(t *testing.T) {
p auth.Principal
want string
}{
- {"global", auth.Principal{Role: auth.RoleGlobal}, "/status"},
- {"outbound", auth.Principal{Role: auth.RoleDomain, Domains: []int64{1}}, "/domains"},
- {"both", auth.Principal{Role: auth.RoleDomain, AllDomains: true, AllInbound: true}, "/domains"},
- {"inbound only", auth.Principal{Role: auth.RoleDomain, InboundDomains: []int64{1}}, "/inbound"},
+ {"global", auth.Principal{Role: auth.RoleGlobal}, "/overview"},
+ {"outbound", auth.Principal{Role: auth.RoleDomain, Domains: []int64{1}}, "/outbound/domains"},
+ {"both", auth.Principal{Role: auth.RoleDomain, AllDomains: true, AllInbound: true}, "/outbound/domains"},
+ {"inbound only", auth.Principal{Role: auth.RoleDomain, InboundDomains: []int64{1}}, "/inbound/domains"},
} {
rec := httptest.NewRecorder()
redirectHome(rec, auth.RequestWithPrincipal(httptest.NewRequest(http.MethodGet, "/", nil), tc.p))
@@ -27,3 +28,36 @@ func TestRedirectHomeFollowsTheReach(t *testing.T) {
}
}
}
+
+// Everything under /server/ is the global role's, and the subtree is guarded
+// where the routes are registered: a domain administrator — whatever they are
+// assigned — and a request with no principal get 404 from the router itself,
+// before any handler runs. The handlers here are nil; reaching one would panic.
+func TestServerSubtreeIsGlobalOnly(t *testing.T) {
+ _, authed := (&Server{cfg: Config{InboundEnabled: true, DMARCEnabled: true}}).muxes()
+ everything := auth.Principal{Role: auth.RoleDomain, AllDomains: true, AllInbound: true}
+ checked := 0
+ for _, pattern := range routes.always {
+ method, target := concrete(pattern)
+ if !strings.HasPrefix(target, "/server/") {
+ continue
+ }
+ if got := answers(authed, method, target); got == "" {
+ continue // not built yet; TestRoutesFollowNavigation reports it
+ }
+ checked++
+ for who, req := range map[string]*http.Request{
+ "a domain administrator with All on both lists": auth.RequestWithPrincipal(httptest.NewRequest(method, target, nil), everything),
+ "no principal": httptest.NewRequest(method, target, nil),
+ } {
+ rec := httptest.NewRecorder()
+ authed.ServeHTTP(rec, req)
+ if rec.Code != http.StatusNotFound {
+ t.Errorf("%s %s as %s = %d, want 404", method, target, who, rec.Code)
+ }
+ }
+ }
+ if checked < 15 {
+ t.Fatalf("only %d /server/ routes were checked", checked)
+ }
+}
diff --git a/internal/web/security.go b/internal/web/security.go
index d3b9120..fc55ccf 100644
--- a/internal/web/security.go
+++ b/internal/web/security.go
@@ -89,7 +89,7 @@ func (s *Server) secure(next http.Handler) http.Handler {
// judged per *origin*, so they tell that neighbour apart from the panel.
//
// Read-only methods are exempt: every GET route in the panel is a read (the
-// delete confirmation at GET /domains/{id}/delete only renders a form), so
+// delete confirmation at GET /outbound/domains/{id}/delete only renders a form), so
// there is nothing for a cross-origin GET to change.
func originAllowed(r *http.Request) bool {
switch r.Method {
diff --git a/internal/web/view/templates/backup.html b/internal/web/view/templates/backup.html
index 04645c2..27fe744 100644
--- a/internal/web/view/templates/backup.html
+++ b/internal/web/view/templates/backup.html
@@ -16,7 +16,7 @@
the download is then a .spbk file (SelfPost backup) that only
the password opens.
{{if .BackupErr}}{{.BackupErr}}
{{end}}
-
@@ -30,7 +30,7 @@
application passwords come across, so the published DNS record needs no
change. The export file is a secret, like a full backup.
{{if .ImportErr}}{{.ImportErr}}
{{end}}
-