panel-redesign: paths follow the menu; Account and Server settings split
Stage 1, step 3 (plan § Routes). BREAKING: every panel URL changes and the
old paths are removed without redirects.
Routes (web.go)
- /status -> /overview; /domains -> /outbound/domains with the domain's
settings actions under .../settings/ (reports, ratelimit, export);
/deliveries -> /outbound/log; /mail-queue -> /outbound/queue; /dmarc ->
/outbound/dmarc; /inbound -> /inbound/domains; /system-log ->
/server/log; /backup -> /server/backup (import under it); /users ->
/server/users; recheck and reload under /server/health/.
- One fragment name: <page>/fragment everywhere.
- Everything under /server/ is registered through server(), which wraps
the handler in globalOnly: the subtree is guarded once, where a new
route cannot forget it. Handlers keep their own requireGlobal.
- GET / leads to /overview, /outbound/domains or /inbound/domains.
- The /account -> /settings redirect is gone with /settings.
Account and Server > Settings
- handlers_account.go: GET /account and three POSTs - profile (username,
e-mail; no password asked, as designed), password (current password,
rate-limited like a sign-in, signs out other sessions), dmarc (the
user's default: hosted / account / custom / none, each validated).
This replaces step 1's bridge through the old Settings form.
- handlers_settings.go is the instance's page only: log retention and the
read-only rate limits, global role.
- One legacy template serves both pages by a flag until stage 2 gives each
its own: a new legacy page cannot be added to the ratchet.
Still to come inside stage 1, and why @routes stays on the ratchet:
- step 4: the application routes (one POST, under the domain);
- step 5: GET /server/health.
With @routes removed by hand TestRoutesFollowNavigation reports exactly
those and nothing else - no template links to a path the router lacks.
Old templates, tests and the e2e client are retargeted. The e2e module was
edited but not run here (no Docker, and its dependency could not be
downloaded on this machine).
Tests: Account (page, profile, password, DMARC default in every mode, a
domain administrator's own row only), Server settings (saved without a
password, range, global only), the /server/ subtree answered 404 by the
router for a domain administrator with All on both lists and for no
principal.
go test ./... green except the two Windows-only failures in internal/domain
and internal/logtail.
Roadmap 13/27.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+24
-4
@@ -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
|
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
|
same 404. A menu group with nothing assigned is absent, and `/` leads an
|
||||||
inbound-only administrator to Inbound.
|
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
|
### 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 `<page>/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
|
- **Breaking — the database starts from zero.** The 1.x migration chain
|
||||||
(`0001`–`0010`) is replaced by one baseline, `0001_init.sql`. A 1.x
|
(`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
|
`/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
|
untouched; nothing is migrated. A domain can be carried over with its
|
||||||
export file.
|
export file.
|
||||||
- The panel role `domain_admin` is stored as `domain`.
|
- The panel role `domain_admin` is stored as `domain`.
|
||||||
- Until the Account page replaces it, the old Settings form's "default report
|
- On the old domain page, "Same as Settings" makes the domain follow the
|
||||||
address" field sets the account e-mail and switches the user's DMARC
|
default report address of the user who chose it.
|
||||||
default between that e-mail and none; a domain's "Same as Settings" makes
|
|
||||||
it follow the user who chose it.
|
|
||||||
- The old panel is untouched on screen but steps aside in the tree: its
|
- 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
|
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
|
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
|
### 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
|
- The instance setting `dmarc_report_email` (a mirror of the global user's
|
||||||
profile field) and the unused `rate_limits.allowed_ips` column.
|
profile field) and the unused `rate_limits.allowed_ips` column.
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ func restoreFromOwnBackup(t *testing.T, password string) restored {
|
|||||||
func TestPanelBootsOnADataDirectoryRestoredFromItsOwnBackup(t *testing.T) {
|
func TestPanelBootsOnADataDirectoryRestoredFromItsOwnBackup(t *testing.T) {
|
||||||
r := restoreFromOwnBackup(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} {
|
for _, want := range []string{restoreDomain, restoreSubject} {
|
||||||
if !strings.Contains(body, want) {
|
if !strings.Contains(body, want) {
|
||||||
t.Errorf("the restored panel's send log does not show %q:\n%s", want, body)
|
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) {
|
func TestARestoredPanelHonoursSessionsFromTheArchive(t *testing.T) {
|
||||||
r := restoreFromOwnBackup(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 {
|
if rec.Code != http.StatusOK {
|
||||||
t.Errorf("a session from before the backup = %d on the restored panel, want 200", rec.Code)
|
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) {
|
func TestAnEncryptedBackupRestoresTheSameWay(t *testing.T) {
|
||||||
r := restoreFromOwnBackup(t, "a-long-enough-password")
|
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) {
|
if !strings.Contains(body, restoreSubject) {
|
||||||
t.Errorf("the panel restored from an encrypted backup lost the send log:\n%s", body)
|
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", password)
|
||||||
form.Set("password_confirm", 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 {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("download a backup = %d, want 200:\n%s", rec.Code, rec.Body.String())
|
t.Fatalf("download a backup = %d, want 200:\n%s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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] 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**
|
- [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**
|
- [ ] 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**
|
- [ ] Server › Health route and handler (tables and *Reload configuration* from `/status`); Overview handler reduced to verdicts — **Opus**
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -39,7 +39,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
|
|||||||
| panel-notifications | E-mail notifications about important events | candidate | — | — |
|
| 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 | — | — |
|
| delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | candidate | — | — |
|
||||||
| password-reset | Password reset by e-mail | 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) |
|
| 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
|
**Recommended order** (not binding; owner, 2026-10-09: feature order follows
|
||||||
|
|||||||
@@ -26,38 +26,38 @@ type route struct {
|
|||||||
var globalOnlyRoutes = []route{
|
var globalOnlyRoutes = []route{
|
||||||
{"GET", "/server/components", func(h *Handlers) http.HandlerFunc { return h.HandleComponents }, nil},
|
{"GET", "/server/components", func(h *Handlers) http.HandlerFunc { return h.HandleComponents }, nil},
|
||||||
|
|
||||||
{"GET", "/users", func(h *Handlers) http.HandlerFunc { return h.HandleUsers }, nil},
|
{"GET", "/server/users", func(h *Handlers) http.HandlerFunc { return h.HandleUsers }, nil},
|
||||||
{"GET", "/users/new", func(h *Handlers) http.HandlerFunc { return h.HandleUserNew }, nil},
|
{"GET", "/server/users/new", func(h *Handlers) http.HandlerFunc { return h.HandleUserNew }, nil},
|
||||||
{"POST", "/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", "/users/1", func(h *Handlers) http.HandlerFunc { return h.HandleUserEdit }, map[string]string{"uid": "1"}},
|
{"GET", "/server/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"}},
|
{"POST", "/server/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"}},
|
{"GET", "/server/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"}},
|
{"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},
|
{"GET", "/server/backup", func(h *Handlers) http.HandlerFunc { return h.HandleBackupPage }, nil},
|
||||||
{"POST", "/backup", func(h *Handlers) http.HandlerFunc { return h.HandleBackup }, nil},
|
{"POST", "/server/backup", func(h *Handlers) http.HandlerFunc { return h.HandleBackup }, nil},
|
||||||
{"POST", "/domains/import", func(h *Handlers) http.HandlerFunc { return h.HandleImportDomain }, 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", "/overview", func(h *Handlers) http.HandlerFunc { return h.HandleStatus }, nil},
|
||||||
{"GET", "/status/fragment", func(h *Handlers) http.HandlerFunc { return h.HandleStatusFragment }, nil},
|
{"GET", "/overview/fragment", func(h *Handlers) http.HandlerFunc { return h.HandleStatusFragment }, nil},
|
||||||
{"POST", "/status/recheck", func(h *Handlers) http.HandlerFunc { return h.HandleStatusRecheck }, 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", "/outbound/queue", func(h *Handlers) http.HandlerFunc { return h.HandleMailQueue }, nil},
|
||||||
{"GET", "/mail-queue/body", func(h *Handlers) http.HandlerFunc { return h.HandleMailQueueBody }, nil},
|
{"GET", "/outbound/queue/fragment", func(h *Handlers) http.HandlerFunc { return h.HandleMailQueueBody }, nil},
|
||||||
{"GET", "/system-log", func(h *Handlers) http.HandlerFunc { return h.HandleSystemLog }, nil},
|
{"GET", "/server/log", func(h *Handlers) http.HandlerFunc { return h.HandleSystemLog }, nil},
|
||||||
{"GET", "/system-log/body", func(h *Handlers) http.HandlerFunc { return h.HandleSystemLogBody }, 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},
|
{"POST", "/outbound/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"}},
|
{"GET", "/outbound/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", "/outbound/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", "/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
|
// 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
|
// pages of one inbound domain are delegated per domain and have their own
|
||||||
// table in handlers_delegation_test.go.
|
// table in handlers_delegation_test.go.
|
||||||
{"POST", "/inbound", func(h *Handlers) http.HandlerFunc { return h.HandleAddInbound }, nil},
|
{"POST", "/inbound/domains", 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"}},
|
{"GET", "/inbound/domains/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/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
|
// 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) {
|
func TestGlobalOnlyRoutesOpenForAGlobalAdministrator(t *testing.T) {
|
||||||
h, _ := serverWithTwoDomains(t)
|
h, _ := serverWithTwoDomains(t)
|
||||||
|
|
||||||
for _, target := range []string{"/users", "/backup"} {
|
for _, target := range []string{"/server/users", "/server/backup"} {
|
||||||
rt := getRoute(t, target)
|
rt := getRoute(t, target)
|
||||||
if rec := call(h, rt, globalPrincipal); rec.Code != http.StatusOK {
|
if rec := call(h, rt, globalPrincipal); rec.Code != http.StatusOK {
|
||||||
t.Errorf("GET %s as a global administrator = %d, want 200:\n%s",
|
t.Errorf("GET %s as a global administrator = %d, want 200:\n%s",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
// checks whether the e2e gate's HTML scrapers still match it.
|
// checks whether the e2e gate's HTML scrapers still match it.
|
||||||
func TestE2ESendLogStatusMarkupDrift(t *testing.T) {
|
func TestE2ESendLogStatusMarkupDrift(t *testing.T) {
|
||||||
h, _ := serverWithDelivery(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.
|
// 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)</span>`)
|
statusCellPattern := regexp.MustCompile(`class="st st-[^"]+">(queued|sent|deferred|bounced|rejected)</span>`)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -290,7 +290,7 @@ func (h *Handlers) HandleDomainDNSRecheck(w http.ResponseWriter, r *http.Request
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.domainDNS(d, record, reportEmail, true)
|
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
|
// 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
|
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
|
// 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)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
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
|
// lookupApplication resolves the {aid} path value to an application, writing a
|
||||||
|
|||||||
@@ -284,7 +284,7 @@ func (h *Handlers) HandleImportDomain(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.followCreator(d.ID, p.ID)
|
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
|
// secretFilePassword reads the "encrypt this download with a password" controls
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
|
|
||||||
// postForm builds the kind of request the backup and export forms submit.
|
// postForm builds the kind of request the backup and export forms submit.
|
||||||
func postForm(values url.Values) *http.Request {
|
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")
|
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
@@ -107,7 +107,7 @@ func TestDecryptErrorMessage(t *testing.T) {
|
|||||||
func TestBackupPageOffersEncryption(t *testing.T) {
|
func TestBackupPageOffersEncryption(t *testing.T) {
|
||||||
h := &Handlers{view: mustView(t), cfg: Config{Version: "test"}}
|
h := &Handlers{view: mustView(t), cfg: Config{Version: "test"}}
|
||||||
rec := httptest.NewRecorder()
|
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.")
|
http.StatusOK, "", "The two passwords do not match.")
|
||||||
|
|
||||||
body := rec.Body.String()
|
body := rec.Body.String()
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ func newInboundStand(t *testing.T) inboundStand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s inboundStand) call(rt inboundRoute, p *auth.Principal, id int64) *httptest.ResponseRecorder {
|
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
|
// 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)
|
s := newInboundStand(t)
|
||||||
const missing = 9999
|
const missing = 9999
|
||||||
for _, rt := range inboundDomainRoutes {
|
for _, rt := range inboundDomainRoutes {
|
||||||
name := rt.method + " /inbound/{id}" + rt.suffix
|
name := rt.method + " /inbound/domains/{id}" + rt.suffix
|
||||||
denied := []struct {
|
denied := []struct {
|
||||||
who string
|
who string
|
||||||
p *auth.Principal
|
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 _, p := range []*auth.Principal{&s.ownsA, &s.allIn, &s.outboundOnly, nil} {
|
||||||
for _, rt := range inboundGlobalRoutes {
|
for _, rt := range inboundGlobalRoutes {
|
||||||
if rec := s.call(rt, p, s.a.ID); rec.Code != http.StatusNotFound {
|
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 {
|
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()
|
list, err := s.h.store.ListInboundDomains()
|
||||||
@@ -200,11 +200,11 @@ func TestAddingAndDeletingInboundDomainsStaysGlobal(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The global role still can.
|
// 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 {
|
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 as the global role = %d:\n%s", rec.Code, rec.Body.String())
|
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 {
|
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) {
|
func TestInboundListShowsOnlyTheAssignedDomains(t *testing.T) {
|
||||||
s := newInboundStand(t)
|
s := newInboundStand(t)
|
||||||
list := func(p *auth.Principal) *httptest.ResponseRecorder {
|
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)
|
rec := list(&s.ownsA)
|
||||||
@@ -266,7 +266,7 @@ func TestInboundListWithNothingLeftIs404(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
p := auth.Principal{ID: u.ID, Username: u.Username, Role: u.Role, InboundDomains: u.InboundDomainIDs}
|
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)
|
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
|
handler http.HandlerFunc
|
||||||
pathValues map[string]string
|
pathValues map[string]string
|
||||||
}{
|
}{
|
||||||
{"GET", "/domains/{id}", h.HandleDomainDetail, did},
|
{"GET", "/outbound/domains/{id}", h.HandleDomainDetail, did},
|
||||||
{"POST", "/domains/{id}/dns-recheck", h.HandleDomainDNSRecheck, did},
|
{"POST", "/outbound/domains/{id}/dns-recheck", h.HandleDomainDNSRecheck, did},
|
||||||
{"POST", "/domains/{id}/applications", h.HandleAddApplication, did},
|
{"POST", "/outbound/domains/{id}/applications", h.HandleAddApplication, did},
|
||||||
{"POST", "/domains/{id}/ratelimit", h.HandleDomainRateLimit, did},
|
{"POST", "/outbound/domains/{id}/settings/ratelimit", h.HandleDomainRateLimit, did},
|
||||||
{"POST", "/domains/{id}/ratelimit/recalc", h.HandleDomainRateLimitRecalc, did},
|
{"POST", "/outbound/domains/{id}/settings/ratelimit/recalc", h.HandleDomainRateLimitRecalc, did},
|
||||||
{"POST", "/domains/{id}/dmarc", h.HandleDomainDMARC, did},
|
{"POST", "/outbound/domains/{id}/settings/reports", h.HandleDomainDMARC, did},
|
||||||
{"POST", "/domains/{id}/export", h.HandleExportDomain, did},
|
{"POST", "/outbound/domains/{id}/settings/export", h.HandleExportDomain, did},
|
||||||
{"POST", "/applications/{aid}/mode", h.HandleUpdateAppMode, aid},
|
{"POST", "/applications/{aid}/mode", h.HandleUpdateAppMode, aid},
|
||||||
{"POST", "/applications/{aid}/authips", h.HandleAppAuthIPs, aid},
|
{"POST", "/applications/{aid}/authips", h.HandleAppAuthIPs, aid},
|
||||||
{"POST", "/applications/{aid}/password", h.HandleRegenPassword, aid},
|
{"POST", "/applications/{aid}/password", h.HandleRegenPassword, aid},
|
||||||
@@ -350,7 +350,7 @@ func TestUserFormAssignsBothDirections(t *testing.T) {
|
|||||||
for k, v := range extra {
|
for k, v := range extra {
|
||||||
form[k] = v
|
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)
|
u, _ := st.GetUserByUsername(username)
|
||||||
return rec, u
|
return rec, u
|
||||||
}
|
}
|
||||||
@@ -374,7 +374,7 @@ func TestUserFormAssignsBothDirections(t *testing.T) {
|
|||||||
update := func(u store.User, form url.Values) store.User {
|
update := func(u store.User, form url.Values) store.User {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
form.Set("username", u.Username)
|
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 {
|
if rec.Code != http.StatusSeeOther {
|
||||||
t.Fatalf("update %s = %d:\n%s", u.Username, rec.Code, rec.Body.String())
|
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
|
h.cfg.InboundEnabled = true
|
||||||
|
|
||||||
// Emptying both lists is refused and changes nothing.
|
// 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"}})
|
url.Values{"username": {u.Username}, "role": {"domain"}})
|
||||||
if after, _ := st.GetUser(u.ID); rec.Code != http.StatusBadRequest || after.Reach().Empty() {
|
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())
|
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.
|
// The form shows both lists with what is ticked.
|
||||||
other, _ := st.GetUserByUsername("inbound-only")
|
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{
|
for _, want := range []string{
|
||||||
`name="all_domains"`, `name="all_inbound_domains"`, "out.example.com",
|
`name="all_domains"`, `name="all_inbound_domains"`, "out.example.com",
|
||||||
`name="inbound_domain_ids" value="` + idStr(in.ID) + `" checked`,
|
`name="inbound_domain_ids" value="` + idStr(in.ID) + `" checked`,
|
||||||
|
|||||||
@@ -72,5 +72,5 @@ func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
h.dns.Forget(d.Name)
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ func TestDomainDMARCFollowsTheUserWhoChoseInherit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
save := func(values url.Values) store.Domain {
|
save := func(values url.Values) store.Domain {
|
||||||
t.Helper()
|
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 {
|
if rec.Code != http.StatusSeeOther {
|
||||||
t.Fatalf("POST %v = %d, want 303:\n%s", values, rec.Code, rec.Body.String())
|
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.
|
// A new domain takes its report address from whoever created it.
|
||||||
func TestFollowCreator(t *testing.T) {
|
func TestFollowCreator(t *testing.T) {
|
||||||
h, _ := settingsServer(t)
|
h, _ := settingsServer(t)
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ func (h *Handlers) HandleAddDomain(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.followCreator(d.ID, p.ID)
|
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
|
// 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)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
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
|
// 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
|
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) {
|
func (h *Handlers) lookupDomain(w http.ResponseWriter, r *http.Request) (store.Domain, bool) {
|
||||||
|
|||||||
@@ -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)
|
"Could not add the domain. Please check the logs and try again.", raw)
|
||||||
return
|
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) {
|
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 != "" {
|
if h.dns != nil && h.cfg.Hostname != "" {
|
||||||
h.dns.InboundMX(d.Name, h.cfg.Hostname, true)
|
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) {
|
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()})
|
h.renderInboundDetail(w, r, http.StatusBadRequest, d, inboundDetailView{TransportErr: err.Error()})
|
||||||
return
|
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) {
|
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()})
|
h.renderInboundDetail(w, r, http.StatusBadRequest, d, inboundDetailView{RecipientErr: err.Error()})
|
||||||
return
|
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) {
|
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)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, "/inbound?deleted=1", http.StatusSeeOther)
|
http.Redirect(w, r, "/inbound/domains?deleted=1", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,16 +51,16 @@ func inboundCall(h *Handlers, method, target string, form url.Values, p auth.Pri
|
|||||||
req = httptest.NewRequest(method, target, nil)
|
req = httptest.NewRequest(method, target, nil)
|
||||||
}
|
}
|
||||||
req = auth.RequestWithPrincipal(req, p)
|
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, "/")
|
id, _, _ := strings.Cut(rest, "/")
|
||||||
if id != "" && id != "delete" {
|
if id != "" && id != "delete" {
|
||||||
req.SetPathValue("id", id)
|
req.SetPathValue("id", id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
case method == http.MethodGet && target == "/inbound":
|
case method == http.MethodGet && target == "/inbound/domains":
|
||||||
h.HandleInboundList(rec, req)
|
h.HandleInboundList(rec, req)
|
||||||
case method == http.MethodPost && target == "/inbound":
|
case method == http.MethodPost && target == "/inbound/domains":
|
||||||
h.HandleAddInbound(rec, req)
|
h.HandleAddInbound(rec, req)
|
||||||
case strings.HasSuffix(target, "/delete") && method == http.MethodGet:
|
case strings.HasSuffix(target, "/delete") && method == http.MethodGet:
|
||||||
h.HandleInboundDeleteConfirm(rec, req)
|
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) {
|
func TestInboundListAndAdd(t *testing.T) {
|
||||||
h, _ := inboundHandlers(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 {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("list = %d\n%s", rec.Code, rec.Body.String())
|
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")
|
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 {
|
if rec.Code != http.StatusSeeOther {
|
||||||
t.Fatalf("add = %d %s", rec.Code, rec.Body.String())
|
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") {
|
if !strings.Contains(rec.Body.String(), "lists.example.com") {
|
||||||
t.Fatalf("list missing domain:\n%s", rec.Body.String())
|
t.Fatalf("list missing domain:\n%s", rec.Body.String())
|
||||||
}
|
}
|
||||||
@@ -100,7 +100,7 @@ func TestInboundListAndAdd(t *testing.T) {
|
|||||||
func TestInboundDisabledIs404(t *testing.T) {
|
func TestInboundDisabledIs404(t *testing.T) {
|
||||||
h, _ := inboundHandlers(t)
|
h, _ := inboundHandlers(t)
|
||||||
h.cfg.InboundEnabled = false
|
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 {
|
if rec.Code != http.StatusNotFound {
|
||||||
t.Fatalf("disabled inbound = %d, want 404", rec.Code)
|
t.Fatalf("disabled inbound = %d, want 404", rec.Code)
|
||||||
}
|
}
|
||||||
@@ -114,14 +114,14 @@ func TestInboundTransportAndRecipients(t *testing.T) {
|
|||||||
}
|
}
|
||||||
id := itoa(d.ID)
|
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"},
|
"host": {"10.0.0.8"}, "port": {"25"}, "tls_mode": {"encrypt"},
|
||||||
}, globalPrincipal)
|
}, globalPrincipal)
|
||||||
if rec.Code != http.StatusSeeOther {
|
if rec.Code != http.StatusSeeOther {
|
||||||
t.Fatalf("upstream = %d %s", rec.Code, rec.Body.String())
|
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"},
|
"recipient_mode": {"list"},
|
||||||
"addresses": {"staff@lists.example.com\nabuse@other.com"},
|
"addresses": {"staff@lists.example.com\nabuse@other.com"},
|
||||||
}, globalPrincipal)
|
}, globalPrincipal)
|
||||||
@@ -129,7 +129,7 @@ func TestInboundTransportAndRecipients(t *testing.T) {
|
|||||||
t.Fatalf("foreign recipient = %d, want 400", rec.Code)
|
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"},
|
"recipient_mode": {"list"},
|
||||||
"addresses": {"staff@lists.example.com"},
|
"addresses": {"staff@lists.example.com"},
|
||||||
}, globalPrincipal)
|
}, globalPrincipal)
|
||||||
@@ -137,7 +137,7 @@ func TestInboundTransportAndRecipients(t *testing.T) {
|
|||||||
t.Fatalf("recipients = %d %s", rec.Code, rec.Body.String())
|
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") {
|
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "10.0.0.8") {
|
||||||
t.Fatalf("detail =\n%s", rec.Body.String())
|
t.Fatalf("detail =\n%s", rec.Body.String())
|
||||||
}
|
}
|
||||||
@@ -150,7 +150,7 @@ func TestInboundDelete(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
id := itoa(d.ID)
|
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 {
|
if rec.Code != http.StatusSeeOther {
|
||||||
t.Fatalf("delete = %d %s", rec.Code, rec.Body.String())
|
t.Fatalf("delete = %d %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -295,9 +295,9 @@ func deliveriesBackURL(r *http.Request) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(back) == 0 {
|
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
|
// sendLogData reads the domain/app filters and page number off the query
|
||||||
|
|||||||
@@ -37,11 +37,11 @@ func TestReadLogTailMissingFileIsNotAnError(t *testing.T) {
|
|||||||
func TestDeliveryLogShowsOnlyTheIdentifyingColumns(t *testing.T) {
|
func TestDeliveryLogShowsOnlyTheIdentifyingColumns(t *testing.T) {
|
||||||
h, row := serverWithDelivery(t)
|
h, row := serverWithDelivery(t)
|
||||||
|
|
||||||
out := getBody(t, h.HandleDeliveries, "/deliveries")
|
out := getBody(t, h.HandleDeliveries, "/outbound/log")
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
row.CreatedAt.Format("2006-01-02 15:04:05"),
|
row.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||||
"noreply@bs.example.ru", "public@example.ru",
|
"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) {
|
if !strings.Contains(out, want) {
|
||||||
t.Errorf("delivery log is missing %q:\n%s", want, out)
|
t.Errorf("delivery log is missing %q:\n%s", want, out)
|
||||||
@@ -63,8 +63,8 @@ func TestDeliveryLogDecodesStoredEncodedSubjects(t *testing.T) {
|
|||||||
h, _ := serverWithDelivery(t)
|
h, _ := serverWithDelivery(t)
|
||||||
|
|
||||||
for name, out := range map[string]string{
|
for name, out := range map[string]string{
|
||||||
"log": getBody(t, h.HandleDeliveries, "/deliveries"),
|
"log": getBody(t, h.HandleDeliveries, "/outbound/log"),
|
||||||
"rows": getBody(t, h.HandleDeliveriesRows, "/deliveries/rows"),
|
"rows": getBody(t, h.HandleDeliveriesRows, "/outbound/log/fragment"),
|
||||||
} {
|
} {
|
||||||
if strings.Contains(out, "=?utf-8?Q?") {
|
if strings.Contains(out, "=?utf-8?Q?") {
|
||||||
t.Errorf("%s shows the subject's MIME encoding instead of its text:\n%s", name, out)
|
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) {
|
func TestDeliveryPageShowsWhatTheLogOmits(t *testing.T) {
|
||||||
h, row := serverWithDelivery(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{
|
for _, want := range []string{
|
||||||
"bs.example.ru", "Queuer3C", "4A1B2C3D", "Проверка",
|
"bs.example.ru", "Queuer3C", "4A1B2C3D", "Проверка",
|
||||||
"noreply@bs.example.ru", "public@example.ru", "sent",
|
"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) {
|
if !strings.Contains(out, want) {
|
||||||
t.Errorf("delivery page is missing %q:\n%s", want, out)
|
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) {
|
func TestDeliveryPageTellsTheMessagesHistory(t *testing.T) {
|
||||||
h, row := serverWithDelivery(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{
|
for _, want := range []string{
|
||||||
"Accepted and queued", "Delivered",
|
"Accepted and queued", "Delivered",
|
||||||
row.CreatedAt.Format("2006-01-02 15:04:05"),
|
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))
|
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"} {
|
for _, want := range []string{"Waiting for a delivery report", "pending", "not yet"} {
|
||||||
if !strings.Contains(out, want) {
|
if !strings.Contains(out, want) {
|
||||||
t.Errorf("delivery page does not mark the message as still waiting (%q):\n%s", want, out)
|
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=<public@example.ru>, dsn=2.0.0, status=sent (250 OK)",
|
"2026-08-03T05:16:03.884210+00:00 host postfix/smtp[26]: 4A1B2C3D: to=<public@example.ru>, 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{
|
for _, want := range []string{
|
||||||
"<th>Time</th>", "<th>Message</th>",
|
"<th>Time</th>", "<th>Message</th>",
|
||||||
// The stamp is split off into its own cell, without the microseconds
|
// 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, row := serverWithDelivery(t)
|
||||||
h.cfg.MailLogPath = writeMailLog(t, "host postfix/smtp[26]: 4A1B2C3D: to=<public@example.ru>, status=sent (250 OK)")
|
h.cfg.MailLogPath = writeMailLog(t, "host postfix/smtp[26]: 4A1B2C3D: to=<public@example.ru>, 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)") {
|
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)
|
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
|
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") {
|
if !strings.Contains(out, "rotated away") {
|
||||||
t.Errorf("delivery page does not explain the empty delivery log:\n%s", out)
|
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) {
|
func TestDeliveryPageNotFound(t *testing.T) {
|
||||||
h, _ := serverWithDelivery(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()
|
rec := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
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)
|
h.HandleDelivery(rec, req)
|
||||||
if rec.Code != http.StatusNotFound {
|
if rec.Code != http.StatusNotFound {
|
||||||
t.Errorf("GET %s = %d, want 404", path, rec.Code)
|
t.Errorf("GET %s = %d, want 404", path, rec.Code)
|
||||||
@@ -266,7 +266,7 @@ func TestSendLogScopedToAssignedDomains(t *testing.T) {
|
|||||||
"page": h.HandleDeliveries,
|
"page": h.HandleDeliveries,
|
||||||
"fragment": h.HandleDeliveriesRows,
|
"fragment": h.HandleDeliveriesRows,
|
||||||
} {
|
} {
|
||||||
out := getBodyAs(t, handler, "/deliveries", p)
|
out := getBodyAs(t, handler, "/outbound/log", p)
|
||||||
for _, want := range tc.want {
|
for _, want := range tc.want {
|
||||||
if !strings.Contains(out, want) {
|
if !strings.Contains(out, want) {
|
||||||
t.Errorf("%s (%s): missing %q:\n%s", name, view, want, out)
|
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)
|
p := domainAdmin(t, h.store, "forged-filter", domains["first.example.ru"].ID)
|
||||||
|
|
||||||
for _, target := range []string{
|
for _, target := range []string{
|
||||||
"/deliveries?domain=second.example.ru",
|
"/outbound/log?domain=second.example.ru",
|
||||||
"/deliveries?app=second-app",
|
"/outbound/log?app=second-app",
|
||||||
"/deliveries?domain=second.example.ru&app=second-app",
|
"/outbound/log?domain=second.example.ru&app=second-app",
|
||||||
} {
|
} {
|
||||||
out := getBodyAs(t, h.HandleDeliveries, target, p)
|
out := getBodyAs(t, h.HandleDeliveries, target, p)
|
||||||
if strings.Contains(out, "Second message") {
|
if strings.Contains(out, "Second message") {
|
||||||
@@ -314,7 +314,7 @@ func TestDeliveryPageForeignDomainNotFound(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
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.SetPathValue("id", itoa(rows[0].ID))
|
||||||
req = auth.RequestWithPrincipal(req, domainAdmin(t, h.store, "foreign-detail", domains["first.example.ru"].ID))
|
req = auth.RequestWithPrincipal(req, domainAdmin(t, h.store, "foreign-detail", domains["first.example.ru"].ID))
|
||||||
h.HandleDelivery(rec, req)
|
h.HandleDelivery(rec, req)
|
||||||
@@ -443,7 +443,7 @@ func getBodyAs(t *testing.T, h http.HandlerFunc, target string, p auth.Principal
|
|||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest(http.MethodGet, target, nil)
|
req := httptest.NewRequest(http.MethodGet, target, nil)
|
||||||
req = auth.RequestWithPrincipal(req, p)
|
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)
|
req.SetPathValue("id", rest)
|
||||||
}
|
}
|
||||||
h(rec, req)
|
h(rec, req)
|
||||||
@@ -483,7 +483,7 @@ func fixtureRetryPolicy() postfix.RetryPolicy {
|
|||||||
func TestMailQueueShowsRetryPolicyCard(t *testing.T) {
|
func TestMailQueueShowsRetryPolicyCard(t *testing.T) {
|
||||||
h := &Handlers{view: mustView(t), cfg: Config{Version: "test", RetryPolicy: fixtureRetryPolicy()}}
|
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{
|
for _, want := range []string{
|
||||||
"How delivery retries work",
|
"How delivery retries work",
|
||||||
"id=\"retry-policy\"",
|
"id=\"retry-policy\"",
|
||||||
@@ -506,7 +506,7 @@ func TestMailQueueShowsRetryPolicyCard(t *testing.T) {
|
|||||||
func TestMailQueueBodyOmitsRetryPolicyCard(t *testing.T) {
|
func TestMailQueueBodyOmitsRetryPolicyCard(t *testing.T) {
|
||||||
h := &Handlers{view: mustView(t), cfg: Config{RetryPolicy: fixtureRetryPolicy()}}
|
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") {
|
if strings.Contains(out, "How delivery retries work") || strings.Contains(out, "10 minutes") {
|
||||||
t.Errorf("HTMX fragment includes the retry card:\n%s", out)
|
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) {
|
func TestMailQueueNotesCompiledInFallback(t *testing.T) {
|
||||||
h := &Handlers{view: mustView(t), cfg: Config{RetryPolicy: postfix.DefaultRetryPolicy()}}
|
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") {
|
if !strings.Contains(out, "compiled-in defaults") {
|
||||||
t.Errorf("fallback note missing:\n%s", out)
|
t.Errorf("fallback note missing:\n%s", out)
|
||||||
}
|
}
|
||||||
@@ -528,7 +528,7 @@ func TestDeliveryPageDeferredUsesRetryPolicy(t *testing.T) {
|
|||||||
t.Fatalf("update status: %v", err)
|
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{
|
for _, want := range []string{
|
||||||
"first after 10 minutes",
|
"first after 10 minutes",
|
||||||
"up to about 1 hour 7 minutes",
|
"up to about 1 hour 7 minutes",
|
||||||
@@ -547,7 +547,7 @@ func TestDeliveryPageBouncedUsesRetryPolicy(t *testing.T) {
|
|||||||
t.Fatalf("update status: %v", err)
|
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") {
|
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)
|
t.Errorf("bounced history does not use the fixture lifetime:\n%s", out)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ func (h *Handlers) HandleDomainRateLimit(w http.ResponseWriter, r *http.Request)
|
|||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
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) {
|
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)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
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) {
|
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)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
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) {
|
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
|
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) {
|
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
|
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 {
|
func (h *Handlers) recalcRateLimit(scope string, refID int64) error {
|
||||||
|
|||||||
@@ -1,283 +1,72 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
|
||||||
"github.com/mixeme/selfpost/internal/store"
|
"github.com/mixeme/selfpost/internal/store"
|
||||||
"github.com/mixeme/selfpost/internal/web/auth"
|
|
||||||
"github.com/mixeme/selfpost/internal/web/validate"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandleSettings serves the signed-in user's panel settings.
|
// Server › Settings holds what is true of the whole instance, whoever is
|
||||||
func (h *Handlers) HandleSettings(w http.ResponseWriter, r *http.Request) {
|
// 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 {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
p, ok := h.principal(r)
|
h.renderServerSettings(w, r, http.StatusOK, "", h.sendLogRetentionDays())
|
||||||
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())
|
|
||||||
case http.MethodPost:
|
case http.MethodPost:
|
||||||
h.submitSettings(w, r)
|
h.submitServerSettings(w, r)
|
||||||
default:
|
default:
|
||||||
w.Header().Set("Allow", "GET, POST")
|
w.Header().Set("Allow", "GET, POST")
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
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) {
|
func (h *Handlers) renderServerSettings(w http.ResponseWriter, r *http.Request, status int, formErr string, formRetention int) {
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
data := h.pageBase(r)
|
data := h.pageBase(r)
|
||||||
data["Title"] = "SelfPost — settings"
|
data["Title"] = "SelfPost — settings"
|
||||||
data["Active"] = "settings"
|
data["Active"] = "settings"
|
||||||
data["FormUsername"] = formUsername
|
data["AccountPage"] = false
|
||||||
data["FormDMARCEmail"] = formDMARCEmail
|
data["FormSendLogRetentionDays"] = formRetention
|
||||||
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["Error"] = formErr
|
data["Error"] = formErr
|
||||||
data["Flash"] = settingsFlash(r)
|
if r.URL.Query().Has("saved") {
|
||||||
|
data["Flash"] = "Settings saved."
|
||||||
|
}
|
||||||
data["L1Messages"] = h.l1Messages()
|
data["L1Messages"] = h.l1Messages()
|
||||||
data["L1Window"] = h.l1Window()
|
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)
|
h.view.Render(w, status, "settings", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func settingsFlash(r *http.Request) string {
|
func (h *Handlers) submitServerSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
q := r.URL.Query()
|
current := h.sendLogRetentionDays()
|
||||||
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
|
|
||||||
}
|
|
||||||
if err := r.ParseForm(); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
days, err := strconv.Atoi(strings.TrimSpace(r.PostFormValue("send_log_retention_days")))
|
||||||
user, err := h.store.GetUser(p.ID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logf("panel: settings: get user failed: %v", err)
|
h.renderServerSettings(w, r, http.StatusBadRequest, "Send log retention must be a whole number of days.", current)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := store.ValidateSendLogRetentionDays(days); err != nil {
|
||||||
username := strings.TrimSpace(r.PostFormValue("username"))
|
h.renderServerSettings(w, r, http.StatusBadRequest, err.Error(), days)
|
||||||
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())
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := h.store.SetSendLogRetentionDays(days); err != nil {
|
||||||
renaming := username != user.Username
|
logf("panel: settings: set send-log retention failed: %v", err)
|
||||||
if renaming {
|
h.renderServerSettings(w, r, http.StatusInternalServerError,
|
||||||
if err := validate.Username(username); err != nil {
|
"Could not save send log retention. Please check the logs and try again.", days)
|
||||||
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())
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
logf("panel: instance settings updated (send-log retention: %d days)", days)
|
||||||
hash := user.PasswordHash
|
http.Redirect(w, r, "/server/settings?saved=1", http.StatusSeeOther)
|
||||||
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, "&")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendLogRetentionDays returns the effective delivery-journal retention window.
|
// sendLogRetentionDays returns the effective delivery-journal retention window.
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -20,37 +19,35 @@ func TestSettingsPageShowsSendLogRetention(t *testing.T) {
|
|||||||
t.Fatalf("SetSendLogRetentionDays: %v", err)
|
t.Fatalf("SetSendLogRetentionDays: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
out := getBody(t, h.HandleSettings, "/settings")
|
out := getBody(t, h.HandleServerSettings, "/server/settings")
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
`id="deliveries-retention"`,
|
`id="deliveries-retention"`,
|
||||||
`name="send_log_retention_days"`,
|
`name="send_log_retention_days"`,
|
||||||
`value="45"`,
|
`value="45"`,
|
||||||
"Send log retention",
|
"Send log retention",
|
||||||
|
`action="/server/settings"`,
|
||||||
|
`id="rate-limits"`,
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(out, want) {
|
if !strings.Contains(out, want) {
|
||||||
t.Errorf("settings page missing %q:\n%s", want, out)
|
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) {
|
func TestSubmitSettingsSavesSendLogRetention(t *testing.T) {
|
||||||
h, password := settingsServer(t)
|
h, _ := settingsServer(t)
|
||||||
|
rec := postFormAs(h.HandleServerSettings, globalPrincipal, "/server/settings", nil, url.Values{"send_log_retention_days": {"120"}})
|
||||||
values := url.Values{
|
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/server/settings?saved=1" {
|
||||||
"username": {"admin"},
|
t.Fatalf("POST /server/settings = %d to %q, want 303:\n%s", rec.Code, rec.Header().Get("Location"), rec.Body.String())
|
||||||
"current_password": {password},
|
|
||||||
"send_log_retention_days": {"120"},
|
|
||||||
}
|
}
|
||||||
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)
|
got, err := h.store.GetSendLogRetentionDays(90)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetSendLogRetentionDays: %v", err)
|
t.Fatalf("GetSendLogRetentionDays: %v", err)
|
||||||
@@ -61,25 +58,35 @@ func TestSubmitSettingsSavesSendLogRetention(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSubmitSettingsRejectsOutOfRangeRetention(t *testing.T) {
|
func TestSubmitSettingsRejectsOutOfRangeRetention(t *testing.T) {
|
||||||
h, password := settingsServer(t)
|
h, _ := settingsServer(t)
|
||||||
|
for _, bad := range []string{"3", "nine", ""} {
|
||||||
values := url.Values{
|
rec := postFormAs(h.HandleServerSettings, globalPrincipal, "/server/settings", nil, url.Values{"send_log_retention_days": {bad}})
|
||||||
"username": {"admin"},
|
if rec.Code != http.StatusBadRequest {
|
||||||
"current_password": {password},
|
t.Fatalf("POST /server/settings (%q) = %d, want 400:\n%s", bad, rec.Code, rec.Body.String())
|
||||||
"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())
|
|
||||||
}
|
}
|
||||||
|
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") {
|
if !strings.Contains(rec.Body.String(), "between 7 and 365") {
|
||||||
t.Errorf("expected range error in body:\n%s", rec.Body.String())
|
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) {
|
func settingsServer(t *testing.T) (*Handlers, string) {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func (h *Handlers) HandleStatusRecheck(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.dns.Server(h.cfg.Hostname, true)
|
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 {
|
func (h *Handlers) statusBody() map[string]any {
|
||||||
|
|||||||
@@ -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."})
|
h.renderUserForm(w, r, http.StatusInternalServerError, 0, userFormView{FormErr: "Could not create user. Please check the logs."})
|
||||||
return
|
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) {
|
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
|
// 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)})
|
h.renderUserForm(w, r, http.StatusInternalServerError, u.ID, userFormView{FormErr: "Could not delete user.", FormUsername: u.Username, FormRole: string(u.Role)})
|
||||||
return
|
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) {
|
func parseUserID(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package web
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/mixeme/selfpost/internal/web/auth"
|
"github.com/mixeme/selfpost/internal/web/auth"
|
||||||
@@ -15,10 +16,10 @@ func TestRedirectHomeFollowsTheReach(t *testing.T) {
|
|||||||
p auth.Principal
|
p auth.Principal
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{"global", auth.Principal{Role: auth.RoleGlobal}, "/status"},
|
{"global", auth.Principal{Role: auth.RoleGlobal}, "/overview"},
|
||||||
{"outbound", auth.Principal{Role: auth.RoleDomain, Domains: []int64{1}}, "/domains"},
|
{"outbound", auth.Principal{Role: auth.RoleDomain, Domains: []int64{1}}, "/outbound/domains"},
|
||||||
{"both", auth.Principal{Role: auth.RoleDomain, AllDomains: true, AllInbound: true}, "/domains"},
|
{"both", auth.Principal{Role: auth.RoleDomain, AllDomains: true, AllInbound: true}, "/outbound/domains"},
|
||||||
{"inbound only", auth.Principal{Role: auth.RoleDomain, InboundDomains: []int64{1}}, "/inbound"},
|
{"inbound only", auth.Principal{Role: auth.RoleDomain, InboundDomains: []int64{1}}, "/inbound/domains"},
|
||||||
} {
|
} {
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
redirectHome(rec, auth.RequestWithPrincipal(httptest.NewRequest(http.MethodGet, "/", nil), tc.p))
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
// 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
|
// 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.
|
// there is nothing for a cross-origin GET to change.
|
||||||
func originAllowed(r *http.Request) bool {
|
func originAllowed(r *http.Request) bool {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
the download is then a <code>.spbk</code> file (SelfPost backup) that only
|
the download is then a <code>.spbk</code> file (SelfPost backup) that only
|
||||||
the password opens.</p>
|
the password opens.</p>
|
||||||
{{if .BackupErr}}<p class="error">{{.BackupErr}}</p>{{end}}
|
{{if .BackupErr}}<p class="error">{{.BackupErr}}</p>{{end}}
|
||||||
<form method="post" action="/backup">
|
<form method="post" action="/server/backup">
|
||||||
{{template "encryptfields" .}}
|
{{template "encryptfields" .}}
|
||||||
<button type="submit">Download full backup</button>
|
<button type="submit">Download full backup</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
application passwords come across, so the published DNS record needs no
|
application passwords come across, so the published DNS record needs no
|
||||||
change. The export file is a secret, like a full backup.</p>
|
change. The export file is a secret, like a full backup.</p>
|
||||||
{{if .ImportErr}}<p class="error">{{.ImportErr}}</p>{{end}}
|
{{if .ImportErr}}<p class="error">{{.ImportErr}}</p>{{end}}
|
||||||
<form method="post" action="/domains/import" enctype="multipart/form-data">
|
<form method="post" action="/server/backup/import" enctype="multipart/form-data">
|
||||||
<label for="importfile">Domain export file</label>
|
<label for="importfile">Domain export file</label>
|
||||||
<input id="importfile" name="file" type="file" accept=".json,.spde,application/json" required data-import-file>
|
<input id="importfile" name="file" type="file" accept=".json,.spde,application/json" required data-import-file>
|
||||||
<div class="encrypt-fields" data-import-password-fields>
|
<div class="encrypt-fields" data-import-password-fields>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
{{if .IsGlobal}}
|
{{if .IsGlobal}}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Add a sending domain</h2>
|
<h2>Add a sending domain</h2>
|
||||||
<form method="post" action="/domains">
|
<form method="post" action="/outbound/domains">
|
||||||
<label for="name">Domain</label>
|
<label for="name">Domain</label>
|
||||||
<div class="input-row">
|
<div class="input-row">
|
||||||
<input id="name" name="name" type="text" placeholder="example.com"
|
<input id="name" name="name" type="text" placeholder="example.com"
|
||||||
@@ -29,11 +29,11 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{{range .Domains}}
|
{{range .Domains}}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="/domains/{{.ID}}">{{.Name}}</a></td>
|
<td><a href="/outbound/domains/{{.ID}}">{{.Name}}</a></td>
|
||||||
<td><a class="st st-{{.DNS}}" href="/domains/{{.ID}}#dns-status">{{.DNS}}</a></td>
|
<td><a class="st st-{{.DNS}}" href="/outbound/domains/{{.ID}}#dns-status">{{.DNS}}</a></td>
|
||||||
<td class="muted">{{.DKIMSelector}}</td>
|
<td class="muted">{{.DKIMSelector}}</td>
|
||||||
<td>{{.AppCount}}</td>
|
<td>{{.AppCount}}</td>
|
||||||
{{if $.IsGlobal}}<td class="actions"><a class="danger" href="/domains/{{.ID}}/delete">Delete</a></td>{{else}}<td></td>{{end}}
|
{{if $.IsGlobal}}<td class="actions"><a class="danger" href="/outbound/domains/{{.ID}}/delete">Delete</a></td>{{else}}<td></td>{{end}}
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -10,11 +10,11 @@
|
|||||||
|
|
||||||
<p class="muted">Rows older than {{.SendLogRetentionDays}} days are deleted
|
<p class="muted">Rows older than {{.SendLogRetentionDays}} days are deleted
|
||||||
automatically.{{if .IsGlobal}} Change retention on
|
automatically.{{if .IsGlobal}} Change retention on
|
||||||
<a href="/settings">Settings</a>.{{end}}</p>
|
<a href="/server/settings">Settings</a>.{{end}}</p>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Filter</h2>
|
<h2>Filter</h2>
|
||||||
<form method="get" action="/deliveries">
|
<form method="get" action="/outbound/log">
|
||||||
<label for="domain">Domain</label>
|
<label for="domain">Domain</label>
|
||||||
<select id="domain" name="domain">
|
<select id="domain" name="domain">
|
||||||
<option value="">All domains</option>
|
<option value="">All domains</option>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{{define "deliveries_rows"}}
|
{{define "deliveries_rows"}}
|
||||||
<div id="deliveries-rows" data-poll aria-live="polite"
|
<div id="deliveries-rows" data-poll aria-live="polite"
|
||||||
hx-get="/deliveries/rows?domain={{.FilterDomain | urlquery}}&app={{.FilterApp | urlquery}}&p={{.Page}}"
|
hx-get="/outbound/log/fragment?domain={{.FilterDomain | urlquery}}&app={{.FilterApp | urlquery}}&p={{.Page}}"
|
||||||
hx-trigger="load" hx-swap="outerHTML">
|
hx-trigger="load" hx-swap="outerHTML">
|
||||||
{{if .Rows}}
|
{{if .Rows}}
|
||||||
<table>
|
<table>
|
||||||
@@ -15,15 +15,15 @@
|
|||||||
<td>{{.To}}</td>
|
<td>{{.To}}</td>
|
||||||
<td class="subject"><span title="{{.Subject}}">{{.Subject}}</span></td>
|
<td class="subject"><span title="{{.Subject}}">{{.Subject}}</span></td>
|
||||||
<td class="status"><span class="st st-{{.Level}}">{{.Status}}</span></td>
|
<td class="status"><span class="st st-{{.Level}}">{{.Status}}</span></td>
|
||||||
<td class="actions"><a href="/deliveries/{{.ID}}?domain={{$.FilterDomain | urlquery}}&app={{$.FilterApp | urlquery}}&p={{$.Page}}">Details</a></td>
|
<td class="actions"><a href="/outbound/log/{{.ID}}?domain={{$.FilterDomain | urlquery}}&app={{$.FilterApp | urlquery}}&p={{$.Page}}">Details</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
Page {{.Page}}{{if .LastPage}} of {{.LastPage}}{{end}}
|
Page {{.Page}}{{if .LastPage}} of {{.LastPage}}{{end}}
|
||||||
{{if .HasPrev}} · <a href="/deliveries?domain={{.FilterDomain | urlquery}}&app={{.FilterApp | urlquery}}&p={{.PrevPage}}">← Newer</a>{{end}}
|
{{if .HasPrev}} · <a href="/outbound/log?domain={{.FilterDomain | urlquery}}&app={{.FilterApp | urlquery}}&p={{.PrevPage}}">← Newer</a>{{end}}
|
||||||
{{if .HasNext}} · <a href="/deliveries?domain={{.FilterDomain | urlquery}}&app={{.FilterApp | urlquery}}&p={{.NextPage}}">Older →</a>{{end}}
|
{{if .HasNext}} · <a href="/outbound/log?domain={{.FilterDomain | urlquery}}&app={{.FilterApp | urlquery}}&p={{.NextPage}}">Older →</a>{{end}}
|
||||||
</p>
|
</p>
|
||||||
{{else}}
|
{{else}}
|
||||||
<p class="muted">No messages logged yet.</p>
|
<p class="muted">No messages logged yet.</p>
|
||||||
|
|||||||
@@ -96,7 +96,7 @@
|
|||||||
<code>mail.log</code>, oldest first — the connection to the receiving server,
|
<code>mail.log</code>, oldest first — the connection to the receiving server,
|
||||||
its reply, and the status that reply was filed as. Only lines carrying this
|
its reply, and the status that reply was filed as. Only lines carrying this
|
||||||
message's queue id are shown; the whole log is on the
|
message's queue id are shown; the whole log is on the
|
||||||
<a href="/system-log">System log</a> page.</p>
|
<a href="/server/log">System log</a> page.</p>
|
||||||
|
|
||||||
{{if .LogRows}}
|
{{if .LogRows}}
|
||||||
{{/* Two columns, the way the send log itself is a table: the times down one
|
{{/* Two columns, the way the send log itself is a table: the times down one
|
||||||
|
|||||||
@@ -34,12 +34,12 @@ XML — not a dashboard.</p>
|
|||||||
{{range .Reports}}
|
{{range .Reports}}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="time muted">{{.ReceivedLabel}}</td>
|
<td class="time muted">{{.ReceivedLabel}}</td>
|
||||||
<td>{{if .DomainID}}<a href="/dmarc/domains/{{.DomainID}}">{{.Domain}}</a>{{else}}{{.Domain}}{{end}}</td>
|
<td>{{if .DomainID}}<a href="/outbound/dmarc/domains/{{.DomainID}}">{{.Domain}}</a>{{else}}{{.Domain}}{{end}}</td>
|
||||||
<td class="muted">{{.Reporter}}</td>
|
<td class="muted">{{.Reporter}}</td>
|
||||||
<td>{{.PeriodLabel}}</td>
|
<td>{{.PeriodLabel}}</td>
|
||||||
<td>{{.PassCount}}</td>
|
<td>{{.PassCount}}</td>
|
||||||
<td>{{.FailCount}}</td>
|
<td>{{.FailCount}}</td>
|
||||||
<td class="actions"><a href="/dmarc/reports/{{.ID}}">View</a></td>
|
<td class="actions"><a href="/outbound/dmarc/reports/{{.ID}}">View</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -47,7 +47,7 @@ XML — not a dashboard.</p>
|
|||||||
<ul class="phone-list phone-only">
|
<ul class="phone-list phone-only">
|
||||||
{{range .Reports}}
|
{{range .Reports}}
|
||||||
<li>
|
<li>
|
||||||
<a href="/dmarc/reports/{{.ID}}">
|
<a href="/outbound/dmarc/reports/{{.ID}}">
|
||||||
<span class="st {{if gt .FailCount 0}}st-warn{{else}}st-ok{{end}}">{{.PassCount}} pass</span>
|
<span class="st {{if gt .FailCount 0}}st-warn{{else}}st-ok{{end}}">{{.PassCount}} pass</span>
|
||||||
<span class="when">{{.ReceivedLabel}}</span>
|
<span class="when">{{.ReceivedLabel}}</span>
|
||||||
<span class="meta">{{.Domain}} · {{.Reporter}}</span>
|
<span class="meta">{{.Domain}} · {{.Reporter}}</span>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<h1>{{.Domain.Name}}</h1>
|
<h1>{{.Domain.Name}}</h1>
|
||||||
{{template "back_link" (back "/dmarc" "All DMARC reports")}}
|
{{template "back_link" (back "/outbound/dmarc" "All DMARC reports")}}
|
||||||
{{template "back_link" (back (printf "/domains/%d" .Domain.ID) (printf "Back to %s" .Domain.Name))}}
|
{{template "back_link" (back (printf "/outbound/domains/%d" .Domain.ID) (printf "Back to %s" .Domain.Name))}}
|
||||||
|
|
||||||
<div class="pair">
|
<div class="pair">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Last {{.WindowDays}} days {{if and (gt .Pass7d 0) (eq .Fail7d 0)}}<span class="st st-ok">pass</span>{{else if gt .Fail7d 0}}<span class="st st-warn">review</span>{{end}}</h2>
|
<h2>Last {{.WindowDays}} days {{if and (gt .Pass7d 0) (eq .Fail7d 0)}}<span class="st st-ok">pass</span>{{else if gt .Fail7d 0}}<span class="st st-warn">review</span>{{end}}</h2>
|
||||||
<p>{{.PolicyHint}}</p>
|
<p>{{.PolicyHint}}</p>
|
||||||
<p class="muted"><a href="/domains/{{.Domain.ID}}#dmarc">Domain DNS and rua=</a></p>
|
<p class="muted"><a href="/outbound/domains/{{.Domain.ID}}#dmarc">Domain DNS and rua=</a></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Totals</h2>
|
<h2>Totals</h2>
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
<td>{{.PeriodLabel}}</td>
|
<td>{{.PeriodLabel}}</td>
|
||||||
<td>{{.PassCount}}</td>
|
<td>{{.PassCount}}</td>
|
||||||
<td>{{.FailCount}}</td>
|
<td>{{.FailCount}}</td>
|
||||||
<td class="actions"><a href="/dmarc/reports/{{.ID}}">View</a></td>
|
<td class="actions"><a href="/outbound/dmarc/reports/{{.ID}}">View</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<span class="st st-ok">{{.Report.PassCount}} pass</span>
|
<span class="st st-ok">{{.Report.PassCount}} pass</span>
|
||||||
{{if gt .Report.FailCount 0}}<span class="st st-warn">{{.Report.FailCount}} fail</span>{{end}}
|
{{if gt .Report.FailCount 0}}<span class="st st-warn">{{.Report.FailCount}} fail</span>{{end}}
|
||||||
</p>
|
</p>
|
||||||
{{template "back_link" (back (printf "/dmarc/domains/%d" .Domain.ID) (printf "Back to %s" .Domain.Name))}}
|
{{template "back_link" (back (printf "/outbound/dmarc/domains/%d" .Domain.ID) (printf "Back to %s" .Domain.Name))}}
|
||||||
|
|
||||||
<div class="pair">
|
<div class="pair">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<h1>Delete {{.Domain.Name}}</h1>
|
<h1>Delete {{.Domain.Name}}</h1>
|
||||||
|
|
||||||
{{template "back_link" (back (printf "/domains/%d" .Domain.ID) (printf "Back to %s" .Domain.Name))}}
|
{{template "back_link" (back (printf "/outbound/domains/%d" .Domain.ID) (printf "Back to %s" .Domain.Name))}}
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Confirm deletion</h2>
|
<h2>Confirm deletion</h2>
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
<p class="muted">This cannot be undone. The published DKIM DNS record becomes
|
<p class="muted">This cannot be undone. The published DKIM DNS record becomes
|
||||||
invalid; remove it from DNS if you do not plan to re-add the domain.</p>
|
invalid; remove it from DNS if you do not plan to re-add the domain.</p>
|
||||||
|
|
||||||
<form method="post" action="/domains/{{.Domain.ID}}/delete">
|
<form method="post" action="/outbound/domains/{{.Domain.ID}}/delete">
|
||||||
<button type="submit" class="danger">Delete {{.Domain.Name}}</button>
|
<button type="submit" class="danger">Delete {{.Domain.Name}}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<h1>{{.Domain.Name}}</h1>
|
<h1>{{.Domain.Name}}</h1>
|
||||||
|
|
||||||
{{template "back_link" (back "/domains" "All domains")}}
|
{{template "back_link" (back "/outbound/domains" "All domains")}}
|
||||||
|
|
||||||
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||||
{{if .RateLimitErr}}<div class="flash error">{{.RateLimitErr}}</div>{{end}}
|
{{if .RateLimitErr}}<div class="flash error">{{.RateLimitErr}}</div>{{end}}
|
||||||
@@ -140,7 +140,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="inline" method="post" action="/domains/{{.Domain.ID}}/dns-recheck">
|
<form class="inline" method="post" action="/outbound/domains/{{.Domain.ID}}/dns-recheck">
|
||||||
<button type="submit">Re-check</button>
|
<button type="submit">Re-check</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -186,7 +186,7 @@
|
|||||||
SelfPost does not receive inbound mail — use a mailbox on another domain.{{end}}</p>
|
SelfPost does not receive inbound mail — use a mailbox on another domain.{{end}}</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if and .DMARCIngestEnabled .ResolvedDMARCEmail}}
|
{{if and .DMARCIngestEnabled .ResolvedDMARCEmail}}
|
||||||
<p class="muted"><a href="/dmarc/domains/{{.Domain.ID}}">View DMARC reports</a> for this domain.</p>
|
<p class="muted"><a href="/outbound/dmarc/domains/{{.Domain.ID}}">View DMARC reports</a> for this domain.</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if .NeedsReportAuth}}
|
{{if .NeedsReportAuth}}
|
||||||
@@ -228,7 +228,7 @@
|
|||||||
above the domains list. */}}
|
above the domains list. */}}
|
||||||
<div class="card" id="add-application">
|
<div class="card" id="add-application">
|
||||||
<h2>Add an application</h2>
|
<h2>Add an application</h2>
|
||||||
<form method="post" action="/domains/{{.Domain.ID}}/applications">
|
<form method="post" action="/outbound/domains/{{.Domain.ID}}/applications">
|
||||||
<label for="login">Login</label>
|
<label for="login">Login</label>
|
||||||
<input id="login" name="login" type="text" placeholder="prod-server"
|
<input id="login" name="login" type="text" placeholder="prod-server"
|
||||||
autocomplete="off" autocapitalize="none" spellcheck="false"
|
autocomplete="off" autocapitalize="none" spellcheck="false"
|
||||||
@@ -418,10 +418,10 @@
|
|||||||
<div class="check-cols check-cols-rows">
|
<div class="check-cols check-cols-rows">
|
||||||
<div class="check-col">
|
<div class="check-col">
|
||||||
<p class="check-col-title">DMARC reports</p>
|
<p class="check-col-title">DMARC reports</p>
|
||||||
<p class="muted">Default comes from <a href="/settings">Settings</a>;
|
<p class="muted">Default comes from <a href="/account#dmarc">Account</a>;
|
||||||
override per domain here.</p>
|
override per domain here.</p>
|
||||||
<div class="check-col-fields">
|
<div class="check-col-fields">
|
||||||
<form id="dmarc-domain" method="post" action="/domains/{{.Domain.ID}}/dmarc">
|
<form id="dmarc-domain" method="post" action="/outbound/domains/{{.Domain.ID}}/settings/reports">
|
||||||
<label for="dmarc_rua_mode">Aggregate reports (rua=)</label>
|
<label for="dmarc_rua_mode">Aggregate reports (rua=)</label>
|
||||||
<select id="dmarc_rua_mode" name="dmarc_rua_mode" data-custom-mode="custom">
|
<select id="dmarc_rua_mode" name="dmarc_rua_mode" data-custom-mode="custom">
|
||||||
<option value="inherit"{{if eq .DMARCRuaMode "inherit"}} selected{{end}}>Same as Settings{{if .ProfileDMARCEmail}} ({{.ProfileDMARCEmail}}){{end}}</option>
|
<option value="inherit"{{if eq .DMARCRuaMode "inherit"}} selected{{end}}>Same as Settings{{if .ProfileDMARCEmail}} ({{.ProfileDMARCEmail}}){{end}}</option>
|
||||||
@@ -446,9 +446,9 @@
|
|||||||
<div class="check-col">
|
<div class="check-col">
|
||||||
<p class="check-col-title">Level-2 rate limit
|
<p class="check-col-title">Level-2 rate limit
|
||||||
{{if .DomainHasRL}}<span class="st st-ok">active</span>{{else}}<span class="st st-unknown">inactive</span>{{end}}</p>
|
{{if .DomainHasRL}}<span class="st st-ok">active</span>{{else}}<span class="st st-unknown">inactive</span>{{end}}</p>
|
||||||
<p class="muted">Level 1 backstop: {{.L1Messages}} messages / {{.L1Window}}s — <a href="/settings#rate-limits">Settings</a>.</p>
|
<p class="muted">Level 1 backstop: {{.L1Messages}} messages / {{.L1Window}}s {{if .IsGlobal}} — <a href="/server/settings#rate-limits">Settings</a>{{end}}.</p>
|
||||||
<div class="check-col-fields">
|
<div class="check-col-fields">
|
||||||
<form id="rl-domain" method="post" action="/domains/{{.Domain.ID}}/ratelimit">
|
<form id="rl-domain" method="post" action="/outbound/domains/{{.Domain.ID}}/settings/ratelimit">
|
||||||
<label for="d_mode">Limit mode</label>
|
<label for="d_mode">Limit mode</label>
|
||||||
<select id="d_mode" name="mode" data-ratelimit-mode>
|
<select id="d_mode" name="mode" data-ratelimit-mode>
|
||||||
<option value="manual" {{if not .DomainRLAuto}}selected{{end}}>Manual</option>
|
<option value="manual" {{if not .DomainRLAuto}}selected{{end}}>Manual</option>
|
||||||
@@ -483,12 +483,12 @@
|
|||||||
<div class="check-col-actions">
|
<div class="check-col-actions">
|
||||||
<button type="submit" form="rl-domain">Save limit</button>
|
<button type="submit" form="rl-domain">Save limit</button>
|
||||||
{{if .DomainRLAuto}}
|
{{if .DomainRLAuto}}
|
||||||
<form class="inline" method="post" action="/domains/{{.Domain.ID}}/ratelimit/recalc">
|
<form class="inline" method="post" action="/outbound/domains/{{.Domain.ID}}/settings/ratelimit/recalc">
|
||||||
<button type="submit">Recalculate now</button>
|
<button type="submit">Recalculate now</button>
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .DomainHasRL}}
|
{{if .DomainHasRL}}
|
||||||
<form class="inline" method="post" action="/domains/{{.Domain.ID}}/ratelimit"
|
<form class="inline" method="post" action="/outbound/domains/{{.Domain.ID}}/settings/ratelimit"
|
||||||
data-confirm="Remove the domain rate limit? Only the global level-1 limit will apply.">
|
data-confirm="Remove the domain rate limit? Only the global level-1 limit will apply.">
|
||||||
<input type="hidden" name="clear" value="1">
|
<input type="hidden" name="clear" value="1">
|
||||||
<button type="submit" class="danger">Remove limit</button>
|
<button type="submit" class="danger">Remove limit</button>
|
||||||
@@ -508,7 +508,7 @@
|
|||||||
<p class="muted"><strong>Secret file</strong> — transfer securely, or encrypt
|
<p class="muted"><strong>Secret file</strong> — transfer securely, or encrypt
|
||||||
below as <code>.spde</code>.</p>
|
below as <code>.spde</code>.</p>
|
||||||
{{if .ExportErr}}<p class="error">{{.ExportErr}}</p>{{end}}
|
{{if .ExportErr}}<p class="error">{{.ExportErr}}</p>{{end}}
|
||||||
<form method="post" action="/domains/{{.Domain.ID}}/export">
|
<form method="post" action="/outbound/domains/{{.Domain.ID}}/settings/export">
|
||||||
{{template "encryptfields" .}}
|
{{template "encryptfields" .}}
|
||||||
<button type="submit">Export domain</button>
|
<button type="submit">Export domain</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -517,7 +517,7 @@
|
|||||||
<div class="card" id="danger">
|
<div class="card" id="danger">
|
||||||
<h2>Danger zone</h2>
|
<h2>Danger zone</h2>
|
||||||
<p class="muted">Deletes the DKIM key and every application on this domain.</p>
|
<p class="muted">Deletes the DKIM key and every application on this domain.</p>
|
||||||
<a class="danger" href="/domains/{{.Domain.ID}}/delete">Delete domain</a>
|
<a class="danger" href="/outbound/domains/{{.Domain.ID}}/delete">Delete domain</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ on the card itself.</p>
|
|||||||
<ul>
|
<ul>
|
||||||
<li><label for="help-status">Status checks</label> — Machine, TLS, PTR,
|
<li><label for="help-status">Status checks</label> — Machine, TLS, PTR,
|
||||||
queue</li>
|
queue</li>
|
||||||
<li><a href="/mail-queue">Mail queue retries</a> — time-based, no attempt
|
<li><a href="/outbound/queue">Mail queue retries</a> — time-based, no attempt
|
||||||
budget (on the queue page)</li>
|
budget (on the queue page)</li>
|
||||||
{{if .InboundEnabled}}
|
{{if .InboundEnabled}}
|
||||||
<li><a href="/inbound">Inbound</a> — not mailboxes; listed recipients or any
|
<li><a href="/inbound/domains">Inbound</a> — not mailboxes; listed recipients or any
|
||||||
address at the domain</li>
|
address at the domain</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ Off by default in Compose.</p>
|
|||||||
{{/* Adding and deleting a domain stay with the global role; a domain
|
{{/* Adding and deleting a domain stay with the global role; a domain
|
||||||
administrator works with the inbound domains assigned to them. */}}
|
administrator works with the inbound domains assigned to them. */}}
|
||||||
{{if .IsGlobal}}
|
{{if .IsGlobal}}
|
||||||
<form method="post" action="/inbound">
|
<form method="post" action="/inbound/domains">
|
||||||
<label for="name">Add inbound domain</label>
|
<label for="name">Add inbound domain</label>
|
||||||
<div class="input-row">
|
<div class="input-row">
|
||||||
<input id="name" name="name" type="text" placeholder="lists.example.com"
|
<input id="name" name="name" type="text" placeholder="lists.example.com"
|
||||||
@@ -33,12 +33,12 @@ Off by default in Compose.</p>
|
|||||||
<tbody>
|
<tbody>
|
||||||
{{range .Domains}}
|
{{range .Domains}}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="/inbound/{{.ID}}">{{.Name}}</a></td>
|
<td><a href="/inbound/domains/{{.ID}}">{{.Name}}</a></td>
|
||||||
<td><a class="st st-{{.DNS}}" href="/inbound/{{.ID}}">{{.DNS}}</a></td>
|
<td><a class="st st-{{.DNS}}" href="/inbound/domains/{{.ID}}">{{.DNS}}</a></td>
|
||||||
<td class="muted">{{.Upstream}}</td>
|
<td class="muted">{{.Upstream}}</td>
|
||||||
<td>{{.RcptLabel}}</td>
|
<td>{{.RcptLabel}}</td>
|
||||||
<td><span class="st st-{{if eq .TLSMode "encrypt"}}ok{{else}}unknown{{end}}">{{.TLSLabel}}</span></td>
|
<td><span class="st st-{{if eq .TLSMode "encrypt"}}ok{{else}}unknown{{end}}">{{.TLSLabel}}</span></td>
|
||||||
<td class="actions">{{if $.IsGlobal}}<a class="danger" href="/inbound/{{.ID}}/delete">Delete</a>{{end}}</td>
|
<td class="actions">{{if $.IsGlobal}}<a class="danger" href="/inbound/domains/{{.ID}}/delete">Delete</a>{{end}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<h1>Delete {{.Domain.Name}}</h1>
|
<h1>Delete {{.Domain.Name}}</h1>
|
||||||
|
|
||||||
{{template "back_link" (back (printf "/inbound/%d" .Domain.ID) (printf "Back to %s" .Domain.Name))}}
|
{{template "back_link" (back (printf "/inbound/domains/%d" .Domain.ID) (printf "Back to %s" .Domain.Name))}}
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Confirm deletion</h2>
|
<h2>Confirm deletion</h2>
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
<p class="muted">This cannot be undone from a backup of inbound maps alone
|
<p class="muted">This cannot be undone from a backup of inbound maps alone
|
||||||
unless you restore one. Remove the MX if you do not plan to re-add the domain.</p>
|
unless you restore one. Remove the MX if you do not plan to re-add the domain.</p>
|
||||||
<form method="post" action="/inbound/{{.Domain.ID}}/delete">
|
<form method="post" action="/inbound/domains/{{.Domain.ID}}/delete">
|
||||||
<button type="submit" class="danger">Delete {{.Domain.Name}}</button>
|
<button type="submit" class="danger">Delete {{.Domain.Name}}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<h1>{{.Domain.Name}}</h1>
|
<h1>{{.Domain.Name}}</h1>
|
||||||
|
|
||||||
{{template "back_link" (back "/inbound" "All inbound domains")}}
|
{{template "back_link" (back "/inbound/domains" "All inbound domains")}}
|
||||||
|
|
||||||
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
{{else}}
|
{{else}}
|
||||||
<p class="error">{{.MX.Detail}}</p>
|
<p class="error">{{.MX.Detail}}</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
<form method="post" action="/inbound/{{.Domain.ID}}/dns-recheck">
|
<form method="post" action="/inbound/domains/{{.Domain.ID}}/dns-recheck">
|
||||||
<button type="submit">Re-check</button>
|
<button type="submit">Re-check</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Upstream</h2>
|
<h2>Upstream</h2>
|
||||||
<p class="muted">Where accepted mail is handed off. Not a mailbox.</p>
|
<p class="muted">Where accepted mail is handed off. Not a mailbox.</p>
|
||||||
<form method="post" action="/inbound/{{.Domain.ID}}/upstream">
|
<form method="post" action="/inbound/domains/{{.Domain.ID}}/upstream">
|
||||||
<label for="host">Host</label>
|
<label for="host">Host</label>
|
||||||
<input id="host" name="host" type="text" value="{{.Domain.Host}}"
|
<input id="host" name="host" type="text" value="{{.Domain.Host}}"
|
||||||
autocomplete="off" autocapitalize="none" spellcheck="false" required>
|
autocomplete="off" autocapitalize="none" spellcheck="false" required>
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
<h2>Valid recipients</h2>
|
<h2>Valid recipients</h2>
|
||||||
<p class="muted">Who this domain accepts on port 25. Same idea as an
|
<p class="muted">Who this domain accepts on port 25. Same idea as an
|
||||||
application's address mode: a list, or any address at the domain.</p>
|
application's address mode: a list, or any address at the domain.</p>
|
||||||
<form method="post" action="/inbound/{{.Domain.ID}}/recipients">
|
<form method="post" action="/inbound/domains/{{.Domain.ID}}/recipients">
|
||||||
<label for="recipient_mode">Who to accept</label>
|
<label for="recipient_mode">Who to accept</label>
|
||||||
<select id="recipient_mode" name="recipient_mode" data-list-mode="list">
|
<select id="recipient_mode" name="recipient_mode" data-list-mode="list">
|
||||||
<option value="list" {{if eq .Domain.RecipientMode "list"}}selected{{end}}>Listed addresses only</option>
|
<option value="list" {{if eq .Domain.RecipientMode "list"}}selected{{end}}>Listed addresses only</option>
|
||||||
@@ -112,7 +112,7 @@
|
|||||||
<h2>Danger zone</h2>
|
<h2>Danger zone</h2>
|
||||||
<p class="muted">Stops accepting mail for this domain. Does not touch
|
<p class="muted">Stops accepting mail for this domain. Does not touch
|
||||||
outbound sending domains.</p>
|
outbound sending domains.</p>
|
||||||
{{if .IsGlobal}}<p><a class="danger" href="/inbound/{{.Domain.ID}}/delete">Delete inbound domain</a></p>{{end}}
|
{{if .IsGlobal}}<p><a class="danger" href="/inbound/domains/{{.Domain.ID}}/delete">Delete inbound domain</a></p>{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -68,38 +68,43 @@
|
|||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
{{/* The order of the blocks is the order they are read in, top to bottom of
|
{{/* The order of the blocks is the order they are read in, top to bottom of
|
||||||
the column: the mark, the panel's pages, and the session last. The mark
|
the column: the mark, the panel's pages, and the session last. The mark
|
||||||
goes to /status, the page the panel opens on — named outright rather
|
goes to /overview, the page the panel opens on — named outright rather
|
||||||
than as "/", which is only a redirect to it. */}}
|
than as "/", which is only a redirect to it. */}}
|
||||||
<a class="brand" href="{{if .IsGlobal}}/status{{else if .HasOutbound}}/domains{{else}}/inbound{{end}}"><img src="/static/logo-compact.svg" width="220" height="100" alt="SelfPost"></a>
|
<a class="brand" href="{{if .IsGlobal}}/overview{{else if .HasOutbound}}/outbound/domains{{else}}/inbound/domains{{end}}"><img src="/static/logo-compact.svg" width="220" height="100" alt="SelfPost"></a>
|
||||||
<div class="links">
|
<div class="links">
|
||||||
{{if .IsGlobal}}
|
{{if .IsGlobal}}
|
||||||
{{if eq .Active "status"}}<span aria-current="page">{{template "icon-status"}}Status</span>{{else}}<a href="/status">{{template "icon-status"}}Status</a>{{end}}
|
{{if eq .Active "status"}}<span aria-current="page">{{template "icon-status"}}Status</span>{{else}}<a href="/overview">{{template "icon-status"}}Status</a>{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
{{/* A group with nothing assigned is absent from that user's menu: the
|
{{/* A group with nothing assigned is absent from that user's menu: the
|
||||||
sending pages need outbound reach, Inbound needs inbound reach. */}}
|
sending pages need outbound reach, Inbound needs inbound reach. */}}
|
||||||
{{if or .IsGlobal .HasOutbound}}
|
{{if or .IsGlobal .HasOutbound}}
|
||||||
{{if eq .Active "domains"}}<span aria-current="page">{{template "icon-domains"}}Domains</span>{{else}}<a href="/domains">{{template "icon-domains"}}Domains</a>{{end}}
|
{{if eq .Active "domains"}}<span aria-current="page">{{template "icon-domains"}}Domains</span>{{else}}<a href="/outbound/domains">{{template "icon-domains"}}Domains</a>{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if and .InboundEnabled (or .IsGlobal .HasInbound)}}
|
{{if and .InboundEnabled (or .IsGlobal .HasInbound)}}
|
||||||
{{if eq .Active "inbound"}}<span aria-current="page">{{template "icon-inbound"}}Inbound</span>{{else}}<a href="/inbound">{{template "icon-inbound"}}Inbound</a>{{end}}
|
{{if eq .Active "inbound"}}<span aria-current="page">{{template "icon-inbound"}}Inbound</span>{{else}}<a href="/inbound/domains">{{template "icon-inbound"}}Inbound</a>{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if and .DMARCEnabled (or .IsGlobal .HasOutbound)}}
|
{{if and .DMARCEnabled (or .IsGlobal .HasOutbound)}}
|
||||||
{{if eq .Active "dmarc"}}<span aria-current="page">{{template "icon-dmarc"}}DMARC</span>{{else}}<a href="/dmarc">{{template "icon-dmarc"}}DMARC</a>{{end}}
|
{{if eq .Active "dmarc"}}<span aria-current="page">{{template "icon-dmarc"}}DMARC</span>{{else}}<a href="/outbound/dmarc">{{template "icon-dmarc"}}DMARC</a>{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if eq .Active "help"}}<span aria-current="page">{{template "icon-help"}}Help</span>{{else}}<a href="/help">{{template "icon-help"}}Help</a>{{end}}
|
{{if eq .Active "help"}}<span aria-current="page">{{template "icon-help"}}Help</span>{{else}}<a href="/help">{{template "icon-help"}}Help</a>{{end}}
|
||||||
{{if or .IsGlobal .HasOutbound}}
|
{{if or .IsGlobal .HasOutbound}}
|
||||||
{{if eq .Active "deliveries"}}<span aria-current="page">{{template "icon-deliveries"}}Deliveries</span>{{else}}<a href="/deliveries">{{template "icon-deliveries"}}Deliveries</a>{{end}}
|
{{if eq .Active "deliveries"}}<span aria-current="page">{{template "icon-deliveries"}}Deliveries</span>{{else}}<a href="/outbound/log">{{template "icon-deliveries"}}Deliveries</a>{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .IsGlobal}}
|
{{if .IsGlobal}}
|
||||||
{{if eq .Active "mail_queue"}}<span aria-current="page">{{template "icon-mail-queue"}}Mail queue</span>{{else}}<a href="/mail-queue">{{template "icon-mail-queue"}}Mail queue</a>{{end}}
|
{{if eq .Active "mail_queue"}}<span aria-current="page">{{template "icon-mail-queue"}}Mail queue</span>{{else}}<a href="/outbound/queue">{{template "icon-mail-queue"}}Mail queue</a>{{end}}
|
||||||
{{if eq .Active "system_log"}}<span aria-current="page">{{template "icon-system-log"}}System log</span>{{else}}<a href="/system-log">{{template "icon-system-log"}}System log</a>{{end}}
|
{{if eq .Active "system_log"}}<span aria-current="page">{{template "icon-system-log"}}System log</span>{{else}}<a href="/server/log">{{template "icon-system-log"}}System log</a>{{end}}
|
||||||
{{if eq .Active "backup"}}<span aria-current="page">{{template "icon-backup"}}Backup</span>{{else}}<a href="/backup">{{template "icon-backup"}}Backup</a>{{end}}
|
{{if eq .Active "backup"}}<span aria-current="page">{{template "icon-backup"}}Backup</span>{{else}}<a href="/server/backup">{{template "icon-backup"}}Backup</a>{{end}}
|
||||||
{{if eq .Active "users"}}<span aria-current="page">{{template "icon-users"}}Users</span>{{else}}<a href="/users">{{template "icon-users"}}Users</a>{{end}}
|
{{if eq .Active "users"}}<span aria-current="page">{{template "icon-users"}}Users</span>{{else}}<a href="/server/users">{{template "icon-users"}}Users</a>{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div class="session">
|
<div class="session">
|
||||||
<span class="session-user muted">{{template "icon-account"}}User: {{.User}}</span>
|
<span class="session-user muted">{{template "icon-account"}}User: {{.User}}</span>
|
||||||
{{if eq .Active "settings"}}<span aria-current="page">{{template "icon-settings"}}Settings</span>{{else}}<a href="/settings">{{template "icon-settings"}}Settings</a>{{end}}
|
{{/* Account is every user's own; Settings is the instance's and belongs
|
||||||
|
to the global role. */}}
|
||||||
|
{{if eq .Active "account"}}<span aria-current="page">{{template "icon-account"}}Account</span>{{else}}<a href="/account">{{template "icon-account"}}Account</a>{{end}}
|
||||||
|
{{if .IsGlobal}}
|
||||||
|
{{if eq .Active "settings"}}<span aria-current="page">{{template "icon-settings"}}Settings</span>{{else}}<a href="/server/settings">{{template "icon-settings"}}Settings</a>{{end}}
|
||||||
|
{{end}}
|
||||||
<form class="inline" method="post" action="/logout">
|
<form class="inline" method="post" action="/logout">
|
||||||
<button type="submit" class="danger">{{template "icon-sign-out"}}Sign out</button>
|
<button type="submit" class="danger">{{template "icon-sign-out"}}Sign out</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -116,7 +121,7 @@
|
|||||||
{{define "wide"}}{{end}}
|
{{define "wide"}}{{end}}
|
||||||
|
|
||||||
{{/* back_link — up-navigation on drill-down pages. Invoke with the "back"
|
{{/* back_link — up-navigation on drill-down pages. Invoke with the "back"
|
||||||
function, e.g. {{template "back_link" (back "/users" "Back to users")}}.
|
function, e.g. {{template "back_link" (back "/server/users" "Back to users")}}.
|
||||||
Place it directly under the page <h1> and above the cards (see .back in
|
Place it directly under the page <h1> and above the cards (see .back in
|
||||||
legacy.css). TestDrillDownPagesPlaceBackLinkAboveContent guards this. */}}
|
legacy.css). TestDrillDownPagesPlaceBackLinkAboveContent guards this. */}}
|
||||||
{{define "back_link"}}
|
{{define "back_link"}}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{{define "mail_queue_body"}}
|
{{define "mail_queue_body"}}
|
||||||
<div id="mail-queue-body" data-poll aria-live="polite" hx-get="/mail-queue/body" hx-trigger="load" hx-swap="outerHTML">
|
<div id="mail-queue-body" data-poll aria-live="polite" hx-get="/outbound/queue/fragment" hx-trigger="load" hx-swap="outerHTML">
|
||||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||||
<span class="code">{{if .Output}}{{.Output}}{{else}}Queue is empty.{{end}}</span>
|
<span class="code">{{if .Output}}{{.Output}}{{else}}Queue is empty.{{end}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,86 +1,119 @@
|
|||||||
{{/* Wide enough for credentials and DMARC settings side by side (see .split
|
{{/* One legacy template for two pages until stage 2 gives each its own:
|
||||||
in panel.css, as on a delivery's page). */}}
|
Account (GET /account, every role) — the user's name and e-mail, their
|
||||||
|
password and their default DMARC report address, each its own form — and
|
||||||
|
Server › Settings (GET /server/settings, global role) — what holds for the
|
||||||
|
whole instance. .AccountPage says which one this is. */}}
|
||||||
{{define "wide"}}wide{{end}}
|
{{define "wide"}}wide{{end}}
|
||||||
|
|
||||||
{{/* The username/password fields are identical for a global administrator
|
|
||||||
(split card, DMARC alongside) and a domain administrator (narrow card,
|
|
||||||
no DMARC card) — only the surrounding form and card differ. */}}
|
|
||||||
{{define "credentials_fields"}}
|
|
||||||
<label for="username">Username</label>
|
|
||||||
<input id="username" name="username" autocomplete="username"
|
|
||||||
autocapitalize="none" spellcheck="false" value="{{.FormUsername}}" required>
|
|
||||||
|
|
||||||
<label for="current_password">Current password</label>
|
|
||||||
<input id="current_password" name="current_password" type="password"
|
|
||||||
autocomplete="current-password" required>
|
|
||||||
|
|
||||||
<label for="new_password">New password</label>
|
|
||||||
<input id="new_password" name="new_password" type="password" autocomplete="new-password">
|
|
||||||
|
|
||||||
<label for="new_password_confirm">Confirm new password</label>
|
|
||||||
<input id="new_password_confirm" name="new_password_confirm" type="password" autocomplete="new-password">
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<h1>Settings</h1>
|
{{if .AccountPage}}
|
||||||
|
<h1>Account</h1>
|
||||||
|
|
||||||
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||||
|
|
||||||
{{if .ShowDMARC}}
|
|
||||||
<form method="post" action="/settings">
|
|
||||||
<div class="split">
|
<div class="split">
|
||||||
<div class="card">
|
<div class="card" id="profile">
|
||||||
<h2>Panel credentials</h2>
|
<h2>Profile</h2>
|
||||||
<p class="muted">These are the credentials for this control panel only.
|
<p class="muted">Signed in as {{.User}} · {{.Role}}. These are the
|
||||||
Applications keep their own logins and passwords, which are not affected.</p>
|
credentials for this control panel only. Applications keep their own logins
|
||||||
|
and passwords, which are not affected.</p>
|
||||||
|
<form method="post" action="/account/profile">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input id="username" name="username" autocomplete="username"
|
||||||
|
autocapitalize="none" spellcheck="false" value="{{.FormUsername}}" required>
|
||||||
|
|
||||||
{{template "credentials_fields" .}}
|
<label for="email">E-mail</label>
|
||||||
|
<input id="email" name="email" type="email" autocomplete="email"
|
||||||
|
autocapitalize="none" spellcheck="false" value="{{.FormEmail}}">
|
||||||
|
<p class="muted">Where the panel writes to you. It can also receive DMARC
|
||||||
|
reports — your choice, under DMARC reports.</p>
|
||||||
|
|
||||||
|
<button type="submit">Save profile</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card" id="password">
|
||||||
<h2>DMARC aggregate reports</h2>
|
<h2>Password</h2>
|
||||||
<p class="muted">Optional default <code>rua=</code> address for every sending
|
<form method="post" action="/account/password">
|
||||||
domain (can be overridden per domain).{{if .DMARCIngestEnabled}} When set to
|
<label for="current_password">Current password</label>
|
||||||
an address on <code>{{.HostedReportAddress}}</code>, SelfPost receives and
|
<input id="current_password" name="current_password" type="password"
|
||||||
parses aggregate reports on port 25.{{else}} Use a mailbox on a domain
|
autocomplete="current-password" required>
|
||||||
that receives inbound mail elsewhere.{{end}}</p>
|
|
||||||
|
|
||||||
<label for="dmarc_report_email">Default report address</label>
|
<label for="new_password">New password</label>
|
||||||
<input id="dmarc_report_email" name="dmarc_report_email" type="email"
|
<input id="new_password" name="new_password" type="password" autocomplete="new-password" required>
|
||||||
autocomplete="email" autocapitalize="none" spellcheck="false"
|
|
||||||
value="{{.FormDMARCEmail}}" placeholder="{{if .DMARCIngestEnabled}}{{.HostedReportAddress}}{{else}}reports@your-mail-domain.com{{end}}">
|
|
||||||
|
|
||||||
{{if .FormDMARCEmail}}
|
<label for="new_password_confirm">Repeat</label>
|
||||||
<p class="muted">When <code>rua=</code> points at another domain, that hub
|
<input id="new_password_confirm" name="new_password_confirm" type="password" autocomplete="new-password" required>
|
||||||
domain must publish a report-authorisation record so receivers will deliver
|
|
||||||
the XML aggregates.</p>
|
|
||||||
|
|
||||||
<label>Report authorization — host / name</label>
|
<button type="submit">Change password</button>
|
||||||
<div class="code-row">
|
</form>
|
||||||
<span class="code">{{.ReportAuthName}}</span>
|
<p class="muted">Changing it signs out every other session of this account;
|
||||||
<button type="button" class="copy">Copy</button>
|
this one stays signed in.</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<label>Report authorization — value</label>
|
|
||||||
<div class="code-row">
|
|
||||||
<span class="code">{{.ReportAuthExample}}</span>
|
|
||||||
<button type="button" class="copy">Copy</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{if .ReportAuthDNS.Status}}
|
|
||||||
<label>Report authorization DNS <span class="st st-{{.ReportAuthDNS.Status}}">{{.ReportAuthDNS.Status}}</span></label>
|
|
||||||
<p class="{{if eq .ReportAuthDNS.Status "ok"}}muted{{else}}error{{end}}">{{.ReportAuthDNS.Detail}}</p>
|
|
||||||
{{if .ReportAuthDNS.Records}}<span class="code">{{range .ReportAuthDNS.Records}}{{.}}
|
|
||||||
{{end}}</span>{{end}}
|
|
||||||
{{end}}
|
|
||||||
{{end}}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card" id="dmarc">
|
||||||
|
<h2>DMARC reports</h2>
|
||||||
|
<p class="muted">Your default report address. It is used by every outbound
|
||||||
|
domain whose report address is set to follow your default; changing it
|
||||||
|
changes the DMARC record those domains must publish.</p>
|
||||||
|
<form method="post" action="/account/dmarc">
|
||||||
|
<label for="dmarc_default">My default report address</label>
|
||||||
|
<select id="dmarc_default" name="dmarc_default" data-custom-mode="custom">
|
||||||
|
{{if .DMARCIngestEnabled}}<option value="hosted"{{if eq .FormDMARCMode "hosted"}} selected{{end}}>SelfPost hosted — read them under DMARC</option>{{end}}
|
||||||
|
<option value="account"{{if eq .FormDMARCMode "account"}} selected{{end}}>My account e-mail{{if .AccountEmail}} — {{.AccountEmail}}{{end}}</option>
|
||||||
|
<option value="custom"{{if eq .FormDMARCMode "custom"}} selected{{end}}>Another address…</option>
|
||||||
|
<option value="none"{{if eq .FormDMARCMode "none"}} selected{{end}}>No reports</option>
|
||||||
|
</select>
|
||||||
|
<div data-custom-address>
|
||||||
|
<label for="dmarc_default_address">Report address</label>
|
||||||
|
<input id="dmarc_default_address" name="dmarc_default_address" type="email"
|
||||||
|
autocomplete="off" autocapitalize="none" spellcheck="false"
|
||||||
|
value="{{.FormDMARCAddress}}" placeholder="reports@your-mail-domain.com">
|
||||||
|
</div>
|
||||||
|
<button type="submit">Save report address</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{{if .ReportAuthName}}
|
||||||
|
<p class="muted">When <code>rua=</code> points at another domain, that hub
|
||||||
|
domain must publish a report-authorisation record so receivers will deliver
|
||||||
|
the XML aggregates.</p>
|
||||||
|
|
||||||
|
<label>Report authorization — host / name</label>
|
||||||
|
<div class="code-row">
|
||||||
|
<span class="code">{{.ReportAuthName}}</span>
|
||||||
|
<button type="button" class="copy">Copy</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label>Report authorization — value</label>
|
||||||
|
<div class="code-row">
|
||||||
|
<span class="code">{{.ReportAuthExample}}</span>
|
||||||
|
<button type="button" class="copy">Copy</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .ReportAuthDNS.Status}}
|
||||||
|
<label>Report authorization DNS <span class="st st-{{.ReportAuthDNS.Status}}">{{.ReportAuthDNS.Status}}</span></label>
|
||||||
|
<p class="{{if eq .ReportAuthDNS.Status "ok"}}muted{{else}}error{{end}}">{{.ReportAuthDNS.Detail}}</p>
|
||||||
|
{{if .ReportAuthDNS.Records}}<span class="code">{{range .ReportAuthDNS.Records}}{{.}}
|
||||||
|
{{end}}</span>{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<h1>Settings</h1>
|
||||||
|
|
||||||
|
<p class="muted">What holds for the whole instance, whoever is signed in. Your
|
||||||
|
own name, e-mail, password and DMARC report address are under
|
||||||
|
<a href="/account">Account</a>.</p>
|
||||||
|
|
||||||
|
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||||
|
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||||
|
|
||||||
|
<form method="post" action="/server/settings">
|
||||||
<div class="card" id="deliveries-retention">
|
<div class="card" id="deliveries-retention">
|
||||||
<h2>Deliveries</h2>
|
<h2>Deliveries</h2>
|
||||||
<p class="muted">How long rows on <a href="/deliveries">Deliveries</a> are
|
<p class="muted">How long rows on <a href="/outbound/log">Deliveries</a> are
|
||||||
kept before the background sweep deletes them — the main driver of
|
kept before the background sweep deletes them — the main driver of
|
||||||
<code>/data</code> growth over time. Does not affect <code>mail.log</code>
|
<code>/data</code> growth over time. Does not affect <code>mail.log</code>
|
||||||
rotation (fourteen daily files).</p>
|
rotation (fourteen daily files).</p>
|
||||||
@@ -89,28 +122,9 @@
|
|||||||
<input id="send_log_retention_days" name="send_log_retention_days" type="number"
|
<input id="send_log_retention_days" name="send_log_retention_days" type="number"
|
||||||
min="7" max="365" step="1" required
|
min="7" max="365" step="1" required
|
||||||
value="{{.FormSendLogRetentionDays}}">
|
value="{{.FormSendLogRetentionDays}}">
|
||||||
|
<button type="submit">Save settings</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit">Save changes</button>
|
|
||||||
<p class="muted">Leave both new-password fields empty to change the username,
|
|
||||||
DMARC address, or send log retention only. Changing the password signs out
|
|
||||||
every other session; this one stays signed in.</p>
|
|
||||||
</form>
|
</form>
|
||||||
{{else}}
|
|
||||||
<div class="card narrow">
|
|
||||||
<h2>Panel credentials</h2>
|
|
||||||
<p class="muted">These are the credentials for this control panel only.
|
|
||||||
Applications keep their own logins and passwords, which are not affected.</p>
|
|
||||||
<form method="post" action="/settings">
|
|
||||||
{{template "credentials_fields" .}}
|
|
||||||
|
|
||||||
<button type="submit">Save changes</button>
|
|
||||||
</form>
|
|
||||||
<p class="muted">Leave both new-password fields empty to change the username
|
|
||||||
only. Changing the password signs out every other session;
|
|
||||||
this one stays signed in.</p>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
<div class="card" id="rate-limits">
|
<div class="card" id="rate-limits">
|
||||||
<h2>Sending rate limits</h2>
|
<h2>Sending rate limits</h2>
|
||||||
@@ -135,3 +149,4 @@ every other session; this one stays signed in.</p>
|
|||||||
allow-list.</p>
|
allow-list.</p>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
restored a backup, or the running configuration looks out of step with the
|
restored a backup, or the running configuration looks out of step with the
|
||||||
domain and application lists. It does not touch the mail queue or the TLS
|
domain and application lists. It does not touch the mail queue or the TLS
|
||||||
certificate, and it is safe to run at any time.</p>
|
certificate, and it is safe to run at any time.</p>
|
||||||
<form class="inline" method="post" action="/reload">
|
<form class="inline" method="post" action="/server/health/reload">
|
||||||
<button type="submit">Reload configuration</button>
|
<button type="submit">Reload configuration</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{{define "status_body"}}
|
{{define "status_body"}}
|
||||||
<div id="status-body" data-poll aria-live="polite" hx-get="/status/fragment" hx-trigger="load" hx-swap="outerHTML">
|
<div id="status-body" data-poll aria-live="polite" hx-get="/overview/fragment" hx-trigger="load" hx-swap="outerHTML">
|
||||||
<div class="card" id="overall">
|
<div class="card" id="overall">
|
||||||
<h2>Overall <span class="st st-{{.OverallStatus}}">{{.OverallStatus}}</span></h2>
|
<h2>Overall <span class="st st-{{.OverallStatus}}">{{.OverallStatus}}</span></h2>
|
||||||
<p class="muted">{{.OverallHeading}}</p>
|
<p class="muted">{{.OverallHeading}}</p>
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
<p class="error">{{.QueueError}}</p>
|
<p class="error">{{.QueueError}}</p>
|
||||||
{{else}}
|
{{else}}
|
||||||
<p>{{if .QueueSummary}}{{.QueueSummary}}{{else}}Mail queue is empty.{{end}}</p>
|
<p>{{if .QueueSummary}}{{.QueueSummary}}{{else}}Mail queue is empty.{{end}}</p>
|
||||||
<a class="btn" href="/mail-queue">View queue</a>
|
<a class="btn" href="/outbound/queue">View queue</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@
|
|||||||
{{end}}</span>
|
{{end}}</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<form class="inline" method="post" action="/status/recheck">
|
<form class="inline" method="post" action="/server/health/recheck">
|
||||||
<button type="submit">Re-check DNS</button>
|
<button type="submit">Re-check DNS</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{{define "system_log_body"}}
|
{{define "system_log_body"}}
|
||||||
<div id="system-log-body" data-poll aria-live="polite" hx-get="/system-log/body" hx-trigger="load" hx-swap="outerHTML">
|
<div id="system-log-body" data-poll aria-live="polite" hx-get="/server/log/fragment" hx-trigger="load" hx-swap="outerHTML">
|
||||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||||
<span class="code">{{if .Lines}}{{range .Lines}}{{.}}
|
<span class="code">{{if .Lines}}{{range .Lines}}{{.}}
|
||||||
{{end}}{{else}}No log lines yet.{{end}}</span>
|
{{end}}{{else}}No log lines yet.{{end}}</span>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<h1>Delete {{.TargetUsername}}</h1>
|
<h1>Delete {{.TargetUsername}}</h1>
|
||||||
|
|
||||||
{{template "back_link" (back (printf "/users/%d" .TargetID) (printf "Back to %s" .TargetUsername))}}
|
{{template "back_link" (back (printf "/server/users/%d" .TargetID) (printf "Back to %s" .TargetUsername))}}
|
||||||
|
|
||||||
<div class="card narrow">
|
<div class="card narrow">
|
||||||
<h2>Confirm deletion</h2>
|
<h2>Confirm deletion</h2>
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
This cannot be undone; a signed-in session for this user stops working
|
This cannot be undone; a signed-in session for this user stops working
|
||||||
immediately.</p>
|
immediately.</p>
|
||||||
|
|
||||||
<form method="post" action="/users/{{.TargetID}}/delete">
|
<form method="post" action="/server/users/{{.TargetID}}/delete">
|
||||||
<button type="submit" class="danger">Delete {{.TargetUsername}}</button>
|
<button type="submit" class="danger">Delete {{.TargetUsername}}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<h1>{{if .IsEdit}}Edit user{{else}}Create user{{end}}</h1>
|
<h1>{{if .IsEdit}}Edit user{{else}}Create user{{end}}</h1>
|
||||||
|
|
||||||
{{template "back_link" (back "/users" "Back to users")}}
|
{{template "back_link" (back "/server/users" "Back to users")}}
|
||||||
|
|
||||||
<div class="card narrow">
|
<div class="card narrow">
|
||||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||||
<form method="post" action="{{if .IsEdit}}/users/{{.UserID}}{{else}}/users/new{{end}}">
|
<form method="post" action="{{if .IsEdit}}/server/users/{{.UserID}}{{else}}/server/users/new{{end}}">
|
||||||
<label for="username">Username</label>
|
<label for="username">Username</label>
|
||||||
<input id="username" name="username" autocomplete="username"
|
<input id="username" name="username" autocomplete="username"
|
||||||
autocapitalize="none" spellcheck="false" value="{{.FormUsername}}" required>
|
autocapitalize="none" spellcheck="false" value="{{.FormUsername}}" required>
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
{{if .LastGlobalLocked}}
|
{{if .LastGlobalLocked}}
|
||||||
<p class="muted">The only global administrator cannot be deleted.</p>
|
<p class="muted">The only global administrator cannot be deleted.</p>
|
||||||
{{else}}
|
{{else}}
|
||||||
<a class="danger" href="/users/{{.UserID}}/delete">Delete user</a>
|
<a class="danger" href="/server/users/{{.UserID}}/delete">Delete user</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<p><a href="/users/new">Create user</a></p>
|
<p><a href="/server/users/new">Create user</a></p>
|
||||||
{{if .Users}}
|
{{if .Users}}
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
<td>{{.User.Role}}</td>
|
<td>{{.User.Role}}</td>
|
||||||
<td class="muted">{{if or (eq .User.Role "global") .User.AllDomains}}All{{else}}{{range $i, $n := .DomainNames}}{{if $i}}, {{end}}{{$n}}{{end}}{{end}}</td>
|
<td class="muted">{{if or (eq .User.Role "global") .User.AllDomains}}All{{else}}{{range $i, $n := .DomainNames}}{{if $i}}, {{end}}{{$n}}{{end}}{{end}}</td>
|
||||||
{{if $.InboundEnabled}}<td class="muted">{{if or (eq .User.Role "global") .User.AllInboundDomains}}All{{else}}{{range $i, $n := .InboundDomainNames}}{{if $i}}, {{end}}{{$n}}{{end}}{{end}}</td>{{end}}
|
{{if $.InboundEnabled}}<td class="muted">{{if or (eq .User.Role "global") .User.AllInboundDomains}}All{{else}}{{range $i, $n := .InboundDomainNames}}{{if $i}}, {{end}}{{$n}}{{end}}{{end}}</td>{{end}}
|
||||||
<td class="actions"><a href="/users/{{.User.ID}}">Edit</a></td>
|
<td class="actions"><a href="/server/users/{{.User.ID}}">Edit</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -150,10 +150,10 @@ func TestNavMarksActivePage(t *testing.T) {
|
|||||||
if !strings.Contains(out, `<span aria-current="page">`) || !strings.Contains(out, `Mail queue</span>`) {
|
if !strings.Contains(out, `<span aria-current="page">`) || !strings.Contains(out, `Mail queue</span>`) {
|
||||||
t.Errorf("active page is not marked:\n%s", out)
|
t.Errorf("active page is not marked:\n%s", out)
|
||||||
}
|
}
|
||||||
if strings.Contains(out, `href="/mail-queue"`) {
|
if strings.Contains(out, `href="/outbound/queue"`) {
|
||||||
t.Errorf("active page still links to itself:\n%s", out)
|
t.Errorf("active page still links to itself:\n%s", out)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out, `href="/deliveries"`) {
|
if !strings.Contains(out, `href="/outbound/log"`) {
|
||||||
t.Errorf("inactive pages are not linked:\n%s", out)
|
t.Errorf("inactive pages are not linked:\n%s", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,13 +175,13 @@ func TestNavLeadsWithStatusAndPointsDomainsAtItsOwnPath(t *testing.T) {
|
|||||||
if !strings.Contains(out, `<span aria-current="page">`) || !strings.Contains(out, `Status</span>`) {
|
if !strings.Contains(out, `<span aria-current="page">`) || !strings.Contains(out, `Status</span>`) {
|
||||||
t.Errorf("the status page is not marked active:\n%s", out)
|
t.Errorf("the status page is not marked active:\n%s", out)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out, `href="/domains"`) {
|
if !strings.Contains(out, `href="/outbound/domains"`) {
|
||||||
t.Errorf("Domains does not link to /domains:\n%s", out)
|
t.Errorf("Domains does not link to /outbound/domains:\n%s", out)
|
||||||
}
|
}
|
||||||
if strings.Index(out, "Status") > strings.Index(out, "Domains") {
|
if strings.Index(out, "Status") > strings.Index(out, "Domains") {
|
||||||
t.Errorf("Status is not the first navigation entry:\n%s", out)
|
t.Errorf("Status is not the first navigation entry:\n%s", out)
|
||||||
}
|
}
|
||||||
if strings.Contains(out, `href="/inbound"`) || strings.Contains(out, "Inbound") {
|
if strings.Contains(out, `href="/inbound/domains"`) || strings.Contains(out, "Inbound") {
|
||||||
t.Errorf("Inbound nav is shown while InboundEnabled is unset:\n%s", out)
|
t.Errorf("Inbound nav is shown while InboundEnabled is unset:\n%s", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,11 +202,11 @@ func TestNavShowsInboundWhenEnabled(t *testing.T) {
|
|||||||
t.Fatalf("execute nav: %v", err)
|
t.Fatalf("execute nav: %v", err)
|
||||||
}
|
}
|
||||||
out := buf.String()
|
out := buf.String()
|
||||||
if !strings.Contains(out, `href="/inbound"`) || !strings.Contains(out, "Inbound") {
|
if !strings.Contains(out, `href="/inbound/domains"`) || !strings.Contains(out, "Inbound") {
|
||||||
t.Errorf("Inbound nav is missing while InboundEnabled is true:\n%s", out)
|
t.Errorf("Inbound nav is missing while InboundEnabled is true:\n%s", out)
|
||||||
}
|
}
|
||||||
dom := strings.Index(out, `href="/domains"`)
|
dom := strings.Index(out, `href="/outbound/domains"`)
|
||||||
inb := strings.Index(out, `href="/inbound"`)
|
inb := strings.Index(out, `href="/inbound/domains"`)
|
||||||
if dom < 0 || inb < 0 || inb < dom {
|
if dom < 0 || inb < 0 || inb < dom {
|
||||||
t.Errorf("Inbound should follow Domains:\n%s", out)
|
t.Errorf("Inbound should follow Domains:\n%s", out)
|
||||||
}
|
}
|
||||||
@@ -355,7 +355,7 @@ func TestDrillDownPagesPlaceBackLinkAboveContent(t *testing.T) {
|
|||||||
func TestNoTemplateLinksToTheBareRoot(t *testing.T) {
|
func TestNoTemplateLinksToTheBareRoot(t *testing.T) {
|
||||||
forEachTemplate(t, func(name, body string) {
|
forEachTemplate(t, func(name, body string) {
|
||||||
if strings.Contains(body, `href="/"`) {
|
if strings.Contains(body, `href="/"`) {
|
||||||
t.Errorf(`%s links to "/", which is now the status redirect; link to /domains (or the intended page) instead`, name)
|
t.Errorf(`%s links to "/", which is now the status redirect; link to /outbound/domains (or the intended page) instead`, name)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -364,8 +364,8 @@ func TestNoTemplateLinksToTheBareRoot(t *testing.T) {
|
|||||||
// page.
|
// page.
|
||||||
func TestReloadFormLivesOnlyOnTheStatusPage(t *testing.T) {
|
func TestReloadFormLivesOnlyOnTheStatusPage(t *testing.T) {
|
||||||
forEachTemplate(t, func(name, body string) {
|
forEachTemplate(t, func(name, body string) {
|
||||||
if strings.Contains(body, `action="/reload"`) && name != "status.html" {
|
if strings.Contains(body, `action="/server/health/reload"`) && name != "status.html" {
|
||||||
t.Errorf("%s still posts to /reload; the reload control belongs on the status page", name)
|
t.Errorf("%s still posts to /server/health/reload; the reload control belongs on the status page", name)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -423,12 +423,12 @@ func TestStatusPageRendersEveryCheck(t *testing.T) {
|
|||||||
out := renderStatusPage(t, statusPageData())
|
out := renderStatusPage(t, statusPageData())
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"opendkim", "FATAL", "Mail queue is empty", "mail.example.com",
|
"opendkim", "FATAL", "Mail queue is empty", "mail.example.com",
|
||||||
"203.0.113.10 → no PTR record", `action="/reload"`,
|
"203.0.113.10 → no PTR record", `action="/server/health/reload"`,
|
||||||
`hx-get="/status/fragment"`, `class="st st-error"`,
|
`hx-get="/overview/fragment"`, `class="st st-error"`,
|
||||||
// Three .split rows inside the polled fragment: machine|processes,
|
// Three .split rows inside the polled fragment: machine|processes,
|
||||||
// queue|certificate, and sockets|hostname. Ids stay on the cards.
|
// queue|certificate, and sockets|hostname. Ids stay on the cards.
|
||||||
`id="processes"`, `id="machine"`, `id="queue"`, `id="certificate"`, `id="sockets"`, `id="hostname"`,
|
`id="processes"`, `id="machine"`, `id="queue"`, `id="certificate"`, `id="sockets"`, `id="hostname"`,
|
||||||
`action="/status/recheck"`,
|
`action="/server/health/recheck"`,
|
||||||
// The machine card: the bars carry their reading in an attribute
|
// The machine card: the bars carry their reading in an attribute
|
||||||
// (the CSP rules out sizing them with a style), and the figures are
|
// (the CSP rules out sizing them with a style), and the figures are
|
||||||
// printed beside them for anything that does not render a meter.
|
// printed beside them for anything that does not render a meter.
|
||||||
@@ -454,7 +454,7 @@ func TestStatusPageRendersEveryCheck(t *testing.T) {
|
|||||||
if !strings.Contains(frag, `id="hostname"`) {
|
if !strings.Contains(frag, `id="hostname"`) {
|
||||||
t.Error("hostname card is outside the polled status-body fragment")
|
t.Error("hostname card is outside the polled status-body fragment")
|
||||||
}
|
}
|
||||||
if strings.Contains(frag, `id="configuration"`) || strings.Contains(frag, `action="/reload"`) {
|
if strings.Contains(frag, `id="configuration"`) || strings.Contains(frag, `action="/server/health/reload"`) {
|
||||||
t.Error("configuration reload must stay outside the polled fragment")
|
t.Error("configuration reload must stay outside the polled fragment")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -599,7 +599,7 @@ func TestMailQueuePageRendersRetryPolicy(t *testing.T) {
|
|||||||
"about 1 hour 7 minutes",
|
"about 1 hour 7 minutes",
|
||||||
"2 days",
|
"2 days",
|
||||||
`id="retry-policy"`,
|
`id="retry-policy"`,
|
||||||
`hx-get="/mail-queue/body"`,
|
`hx-get="/outbound/queue/fragment"`,
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(out, want) {
|
if !strings.Contains(out, want) {
|
||||||
t.Errorf("mail_queue is missing %q:\n%s", want, out)
|
t.Errorf("mail_queue is missing %q:\n%s", want, out)
|
||||||
@@ -709,22 +709,22 @@ func TestLegacyNavFollowsTheUsersReach(t *testing.T) {
|
|||||||
has := func(out, href string) bool { return strings.Contains(out, `href="`+href+`"`) }
|
has := func(out, href string) bool { return strings.Contains(out, `href="`+href+`"`) }
|
||||||
|
|
||||||
out := nav(map[string]any{"HasInbound": true})
|
out := nav(map[string]any{"HasInbound": true})
|
||||||
if !has(out, "/inbound") {
|
if !has(out, "/inbound/domains") {
|
||||||
t.Errorf("an inbound-only administrator has no Inbound entry:\n%s", out)
|
t.Errorf("an inbound-only administrator has no Inbound entry:\n%s", out)
|
||||||
}
|
}
|
||||||
for _, gone := range []string{"/domains", "/deliveries", "/dmarc", "/status", "/users", "/mail-queue"} {
|
for _, gone := range []string{"/outbound/domains", "/outbound/log", "/outbound/dmarc", "/overview", "/server/users", "/outbound/queue"} {
|
||||||
if has(out, gone) {
|
if has(out, gone) {
|
||||||
t.Errorf("an inbound-only administrator is offered %s", gone)
|
t.Errorf("an inbound-only administrator is offered %s", gone)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
out = nav(map[string]any{"HasOutbound": true})
|
out = nav(map[string]any{"HasOutbound": true})
|
||||||
if has(out, "/inbound") || !has(out, "/domains") || !has(out, "/deliveries") || !has(out, "/dmarc") {
|
if has(out, "/inbound/domains") || !has(out, "/outbound/domains") || !has(out, "/outbound/log") || !has(out, "/outbound/dmarc") {
|
||||||
t.Errorf("an outbound-only administrator's menu is wrong:\n%s", out)
|
t.Errorf("an outbound-only administrator's menu is wrong:\n%s", out)
|
||||||
}
|
}
|
||||||
|
|
||||||
out = nav(map[string]any{"IsGlobal": true})
|
out = nav(map[string]any{"IsGlobal": true})
|
||||||
for _, want := range []string{"/status", "/domains", "/inbound", "/dmarc", "/deliveries", "/users"} {
|
for _, want := range []string{"/overview", "/outbound/domains", "/inbound/domains", "/outbound/dmarc", "/outbound/log", "/server/users"} {
|
||||||
if !has(out, want) {
|
if !has(out, want) {
|
||||||
t.Errorf("the global role lost %s", want)
|
t.Errorf("the global role lost %s", want)
|
||||||
}
|
}
|
||||||
|
|||||||
+89
-59
@@ -7,6 +7,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/mixeme/selfpost/internal/app"
|
"github.com/mixeme/selfpost/internal/app"
|
||||||
"github.com/mixeme/selfpost/internal/dmarc"
|
"github.com/mixeme/selfpost/internal/dmarc"
|
||||||
@@ -169,83 +170,112 @@ func (s *Server) muxes() (public, authed *http.ServeMux) {
|
|||||||
mux.HandleFunc("/logout", s.auth.HandleLogout)
|
mux.HandleFunc("/logout", s.auth.HandleLogout)
|
||||||
|
|
||||||
authed = http.NewServeMux()
|
authed = http.NewServeMux()
|
||||||
|
// A URL reads like the menu: /<group>/<page>, an entity under its list, an
|
||||||
|
// action under the thing it changes (docs/plans/panel-redesign.md §
|
||||||
|
// Routes). The pre-2.0 paths are gone, not redirected.
|
||||||
authed.HandleFunc("GET /{$}", redirectHome)
|
authed.HandleFunc("GET /{$}", redirectHome)
|
||||||
authed.HandleFunc("GET /help", h.HandleHelp)
|
authed.HandleFunc("GET /help", h.HandleHelp)
|
||||||
authed.HandleFunc("GET /server/components", h.HandleComponents)
|
|
||||||
|
|
||||||
authed.HandleFunc("GET /status", h.HandleStatus)
|
// Account: every signed-in user's own.
|
||||||
authed.HandleFunc("GET /status/fragment", h.HandleStatusFragment)
|
authed.HandleFunc("GET /account", h.HandleAccount)
|
||||||
authed.HandleFunc("POST /status/recheck", h.HandleStatusRecheck)
|
authed.HandleFunc("POST /account/profile", h.HandleAccountProfile)
|
||||||
|
authed.HandleFunc("POST /account/password", h.HandleAccountPassword)
|
||||||
|
authed.HandleFunc("POST /account/dmarc", h.HandleAccountDMARC)
|
||||||
|
|
||||||
authed.HandleFunc("GET /domains", h.HandleDashboard)
|
// Overview. Its handler checks the role itself: the page is global-only
|
||||||
authed.HandleFunc("POST /domains", h.HandleAddDomain)
|
// but does not live under /server/.
|
||||||
authed.HandleFunc("POST /domains/import", h.HandleImportDomain)
|
authed.HandleFunc("GET /overview", h.HandleStatus)
|
||||||
authed.HandleFunc("GET /domains/{id}", h.HandleDomainDetail)
|
authed.HandleFunc("GET /overview/fragment", h.HandleStatusFragment)
|
||||||
authed.HandleFunc("POST /domains/{id}/dns-recheck", h.HandleDomainDNSRecheck)
|
|
||||||
authed.HandleFunc("GET /domains/{id}/delete", h.HandleDeleteConfirm)
|
// Outbound: checked per domain in the handlers (lookupDomain,
|
||||||
authed.HandleFunc("POST /domains/{id}/delete", h.HandleDeleteDomain)
|
// lookupApplication), adding and deleting by role.
|
||||||
authed.HandleFunc("POST /domains/{id}/applications", h.HandleAddApplication)
|
authed.HandleFunc("GET /outbound/domains", h.HandleDashboard)
|
||||||
authed.HandleFunc("POST /domains/{id}/ratelimit", h.HandleDomainRateLimit)
|
authed.HandleFunc("POST /outbound/domains", h.HandleAddDomain)
|
||||||
authed.HandleFunc("POST /domains/{id}/ratelimit/recalc", h.HandleDomainRateLimitRecalc)
|
authed.HandleFunc("GET /outbound/domains/{id}", h.HandleDomainDetail)
|
||||||
authed.HandleFunc("POST /domains/{id}/dmarc", h.HandleDomainDMARC)
|
authed.HandleFunc("POST /outbound/domains/{id}/dns-recheck", h.HandleDomainDNSRecheck)
|
||||||
authed.HandleFunc("POST /domains/{id}/export", h.HandleExportDomain)
|
authed.HandleFunc("GET /outbound/domains/{id}/delete", h.HandleDeleteConfirm)
|
||||||
|
authed.HandleFunc("POST /outbound/domains/{id}/delete", h.HandleDeleteDomain)
|
||||||
|
// Until the domain page is split in two (stage 2), its settings half is
|
||||||
|
// still the lower part of the one old page.
|
||||||
|
authed.HandleFunc("GET /outbound/domains/{id}/settings", h.HandleDomainDetail)
|
||||||
|
authed.HandleFunc("POST /outbound/domains/{id}/settings/reports", h.HandleDomainDMARC)
|
||||||
|
authed.HandleFunc("POST /outbound/domains/{id}/settings/ratelimit", h.HandleDomainRateLimit)
|
||||||
|
authed.HandleFunc("POST /outbound/domains/{id}/settings/ratelimit/recalc", h.HandleDomainRateLimitRecalc)
|
||||||
|
authed.HandleFunc("POST /outbound/domains/{id}/settings/export", h.HandleExportDomain)
|
||||||
|
authed.HandleFunc("POST /outbound/domains/{id}/applications", h.HandleAddApplication)
|
||||||
authed.HandleFunc("POST /applications/{aid}/mode", h.HandleUpdateAppMode)
|
authed.HandleFunc("POST /applications/{aid}/mode", h.HandleUpdateAppMode)
|
||||||
authed.HandleFunc("POST /applications/{aid}/authips", h.HandleAppAuthIPs)
|
authed.HandleFunc("POST /applications/{aid}/authips", h.HandleAppAuthIPs)
|
||||||
authed.HandleFunc("POST /applications/{aid}/password", h.HandleRegenPassword)
|
authed.HandleFunc("POST /applications/{aid}/password", h.HandleRegenPassword)
|
||||||
authed.HandleFunc("POST /applications/{aid}/ratelimit", h.HandleAppRateLimit)
|
authed.HandleFunc("POST /applications/{aid}/ratelimit", h.HandleAppRateLimit)
|
||||||
authed.HandleFunc("POST /applications/{aid}/ratelimit/recalc", h.HandleAppRateLimitRecalc)
|
authed.HandleFunc("POST /applications/{aid}/ratelimit/recalc", h.HandleAppRateLimitRecalc)
|
||||||
authed.HandleFunc("POST /applications/{aid}/delete", h.HandleDeleteApplication)
|
authed.HandleFunc("POST /applications/{aid}/delete", h.HandleDeleteApplication)
|
||||||
authed.HandleFunc("POST /reload", h.HandleReload)
|
|
||||||
|
|
||||||
if s.cfg.InboundEnabled {
|
authed.HandleFunc("GET /outbound/log", h.HandleDeliveries)
|
||||||
authed.HandleFunc("GET /inbound", h.HandleInboundList)
|
authed.HandleFunc("GET /outbound/log/fragment", h.HandleDeliveriesRows)
|
||||||
authed.HandleFunc("POST /inbound", h.HandleAddInbound)
|
authed.HandleFunc("GET /outbound/log/{id}", h.HandleDelivery)
|
||||||
authed.HandleFunc("GET /inbound/{id}", h.HandleInboundDetail)
|
authed.HandleFunc("GET /outbound/queue", h.HandleMailQueue)
|
||||||
authed.HandleFunc("POST /inbound/{id}/dns-recheck", h.HandleInboundDNSRecheck)
|
authed.HandleFunc("GET /outbound/queue/fragment", h.HandleMailQueueBody)
|
||||||
authed.HandleFunc("POST /inbound/{id}/upstream", h.HandleInboundTransport)
|
|
||||||
authed.HandleFunc("POST /inbound/{id}/recipients", h.HandleInboundRecipients)
|
|
||||||
authed.HandleFunc("GET /inbound/{id}/delete", h.HandleInboundDeleteConfirm)
|
|
||||||
authed.HandleFunc("POST /inbound/{id}/delete", h.HandleInboundDelete)
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.cfg.DMARCEnabled {
|
if s.cfg.DMARCEnabled {
|
||||||
authed.HandleFunc("GET /dmarc", h.HandleDMARCList)
|
authed.HandleFunc("GET /outbound/dmarc", h.HandleDMARCList)
|
||||||
authed.HandleFunc("GET /dmarc/reports/{id}", h.HandleDMARCReport)
|
authed.HandleFunc("GET /outbound/dmarc/reports/{id}", h.HandleDMARCReport)
|
||||||
authed.HandleFunc("GET /dmarc/domains/{id}", h.HandleDMARCDomain)
|
authed.HandleFunc("GET /outbound/dmarc/domains/{id}", h.HandleDMARCDomain)
|
||||||
}
|
}
|
||||||
|
|
||||||
authed.HandleFunc("/settings", h.HandleSettings)
|
// Inbound: checked per inbound domain (requireInboundDomain).
|
||||||
authed.HandleFunc("/account", redirectSettings)
|
if s.cfg.InboundEnabled {
|
||||||
|
authed.HandleFunc("GET /inbound/domains", h.HandleInboundList)
|
||||||
|
authed.HandleFunc("POST /inbound/domains", h.HandleAddInbound)
|
||||||
|
authed.HandleFunc("GET /inbound/domains/{id}", h.HandleInboundDetail)
|
||||||
|
authed.HandleFunc("POST /inbound/domains/{id}/dns-recheck", h.HandleInboundDNSRecheck)
|
||||||
|
authed.HandleFunc("POST /inbound/domains/{id}/upstream", h.HandleInboundTransport)
|
||||||
|
authed.HandleFunc("POST /inbound/domains/{id}/recipients", h.HandleInboundRecipients)
|
||||||
|
authed.HandleFunc("GET /inbound/domains/{id}/delete", h.HandleInboundDeleteConfirm)
|
||||||
|
authed.HandleFunc("POST /inbound/domains/{id}/delete", h.HandleInboundDelete)
|
||||||
|
}
|
||||||
|
|
||||||
authed.HandleFunc("GET /users", h.HandleUsers)
|
// Server: everything under /server/ is for the global role, so the subtree
|
||||||
authed.HandleFunc("GET /users/new", h.HandleUserNew)
|
// is guarded once, here, by server() — a route added to this group cannot
|
||||||
authed.HandleFunc("POST /users/new", h.HandleUserNew)
|
// forget the check. The handlers keep their own requireGlobal as well.
|
||||||
authed.HandleFunc("GET /users/{uid}", h.HandleUserEdit)
|
server := func(pattern string, handler http.HandlerFunc) {
|
||||||
authed.HandleFunc("POST /users/{uid}", h.HandleUserEdit)
|
if _, path, _ := strings.Cut(pattern, " "); !strings.HasPrefix(path, "/server/") {
|
||||||
authed.HandleFunc("GET /users/{uid}/delete", h.HandleUserDeleteConfirm)
|
panic("web: server() registers routes under /server/ only: " + pattern)
|
||||||
authed.HandleFunc("POST /users/{uid}/delete", h.HandleUserDelete)
|
}
|
||||||
|
authed.HandleFunc(pattern, globalOnly(handler))
|
||||||
authed.HandleFunc("GET /backup", h.HandleBackupPage)
|
}
|
||||||
authed.HandleFunc("POST /backup", h.HandleBackup)
|
server("POST /server/health/recheck", h.HandleStatusRecheck)
|
||||||
|
server("POST /server/health/reload", h.HandleReload)
|
||||||
authed.HandleFunc("GET /deliveries", h.HandleDeliveries)
|
server("GET /server/log", h.HandleSystemLog)
|
||||||
authed.HandleFunc("GET /deliveries/rows", h.HandleDeliveriesRows)
|
server("GET /server/log/fragment", h.HandleSystemLogBody)
|
||||||
authed.HandleFunc("GET /deliveries/{id}", h.HandleDelivery)
|
server("GET /server/backup", h.HandleBackupPage)
|
||||||
authed.HandleFunc("GET /mail-queue", h.HandleMailQueue)
|
server("POST /server/backup", h.HandleBackup)
|
||||||
authed.HandleFunc("GET /mail-queue/body", h.HandleMailQueueBody)
|
server("POST /server/backup/import", h.HandleImportDomain)
|
||||||
authed.HandleFunc("GET /system-log", h.HandleSystemLog)
|
server("GET /server/users", h.HandleUsers)
|
||||||
authed.HandleFunc("GET /system-log/body", h.HandleSystemLogBody)
|
server("GET /server/users/new", h.HandleUserNew)
|
||||||
|
server("POST /server/users/new", h.HandleUserNew)
|
||||||
|
server("GET /server/users/{uid}", h.HandleUserEdit)
|
||||||
|
server("POST /server/users/{uid}", h.HandleUserEdit)
|
||||||
|
server("GET /server/users/{uid}/delete", h.HandleUserDeleteConfirm)
|
||||||
|
server("POST /server/users/{uid}/delete", h.HandleUserDelete)
|
||||||
|
server("GET /server/settings", h.HandleServerSettings)
|
||||||
|
server("POST /server/settings", h.HandleServerSettings)
|
||||||
|
server("GET /server/components", h.HandleComponents)
|
||||||
|
|
||||||
return mux, authed
|
return mux, authed
|
||||||
}
|
}
|
||||||
|
|
||||||
// redirectSettings sends legacy /account bookmarks to /settings (308 preserves POST).
|
// globalOnly answers 404 to anyone but the global role before the wrapped
|
||||||
func redirectSettings(w http.ResponseWriter, r *http.Request) {
|
// handler runs — the same answer a missing page gets, so the panel does not
|
||||||
target := "/settings"
|
// confirm to a domain administrator that the Server pages exist.
|
||||||
if r.URL.RawQuery != "" {
|
func globalOnly(next http.HandlerFunc) http.HandlerFunc {
|
||||||
target += "?" + r.URL.RawQuery
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p, ok := auth.PrincipalFromRequest(r)
|
||||||
|
if !ok || !p.IsGlobal() {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next(w, r)
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, target, http.StatusPermanentRedirect)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func redirectHome(w http.ResponseWriter, r *http.Request) {
|
func redirectHome(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -254,13 +284,13 @@ func redirectHome(w http.ResponseWriter, r *http.Request) {
|
|||||||
// A domain administrator lands on what they reach: their sending
|
// A domain administrator lands on what they reach: their sending
|
||||||
// domains, or the inbound ones when that is all they are assigned.
|
// domains, or the inbound ones when that is all they are assigned.
|
||||||
if !p.HasOutbound() && p.HasInbound() {
|
if !p.HasOutbound() && p.HasInbound() {
|
||||||
http.Redirect(w, r, "/inbound", http.StatusSeeOther)
|
http.Redirect(w, r, "/inbound/domains", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, "/domains", http.StatusSeeOther)
|
http.Redirect(w, r, "/outbound/domains", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, "/status", http.StatusSeeOther)
|
http.Redirect(w, r, "/overview", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleHealth(w http.ResponseWriter, _ *http.Request) {
|
func handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
|||||||
@@ -141,22 +141,22 @@ func TestE2E(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
run("inbound_ui_absent_when_disabled", func(t *testing.T) {
|
run("inbound_ui_absent_when_disabled", func(t *testing.T) {
|
||||||
resp, body, err := sc.panel.get("/status")
|
resp, body, err := sc.panel.get("/overview")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
t.Fatalf("status = %d", resp.StatusCode)
|
t.Fatalf("status = %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
if strings.Contains(body, `href="/inbound"`) {
|
if strings.Contains(body, `href="/inbound/domains"`) {
|
||||||
t.Fatal("status page shows Inbound nav while INBOUND_RELAY_ENABLE is off")
|
t.Fatal("status page shows Inbound nav while INBOUND_RELAY_ENABLE is off")
|
||||||
}
|
}
|
||||||
resp, _, err = sc.panel.get("/inbound")
|
resp, _, err = sc.panel.get("/inbound/domains")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if resp.StatusCode != 404 {
|
if resp.StatusCode != 404 {
|
||||||
t.Fatalf("GET /inbound = %d, want 404 with inbound relay off", resp.StatusCode)
|
t.Fatalf("GET /inbound/domains = %d, want 404 with inbound relay off", resp.StatusCode)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -187,10 +187,10 @@ func testSessionSurvivesRestart(t *testing.T, sc *scenario) {
|
|||||||
}
|
}
|
||||||
resp, err := sc.panel.status()
|
resp, err := sc.panel.status()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GET /status after restart: %v", err)
|
t.Fatalf("GET /overview after restart: %v", err)
|
||||||
}
|
}
|
||||||
if resp.Request.URL.Path != "/status" {
|
if resp.Request.URL.Path != "/overview" {
|
||||||
t.Fatalf("session did not survive restart: landed on %s instead of /status", resp.Request.URL.Path)
|
t.Fatalf("session did not survive restart: landed on %s instead of /overview", resp.Request.URL.Path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
-13
@@ -130,20 +130,20 @@ func (c *panelClient) login(username, password string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if resp.Request.URL.Path != "/status" {
|
if resp.Request.URL.Path != "/overview" {
|
||||||
return fmt.Errorf("login did not land on /status (landed on %s): %s", resp.Request.URL.Path, body)
|
return fmt.Errorf("login did not land on /overview (landed on %s): %s", resp.Request.URL.Path, body)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// addDomain submits the add-domain form and returns its assigned id, read
|
// addDomain submits the add-domain form and returns its assigned id, read
|
||||||
// back from the redirect target /domains/{id}.
|
// back from the redirect target /outbound/domains/{id}.
|
||||||
func (c *panelClient) addDomain(name string) (string, error) {
|
func (c *panelClient) addDomain(name string) (string, error) {
|
||||||
resp, body, err := c.postForm("/domains", url.Values{"name": {name}})
|
resp, body, err := c.postForm("/outbound/domains", url.Values{"name": {name}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
m := regexp.MustCompile(`^/domains/(\d+)$`).FindStringSubmatch(resp.Request.URL.Path)
|
m := regexp.MustCompile(`^/outbound/domains/(\d+)$`).FindStringSubmatch(resp.Request.URL.Path)
|
||||||
if m == nil {
|
if m == nil {
|
||||||
return "", fmt.Errorf("add domain %q: unexpected landing page %s: %s", name, resp.Request.URL.Path, body)
|
return "", fmt.Errorf("add domain %q: unexpected landing page %s: %s", name, resp.Request.URL.Path, body)
|
||||||
}
|
}
|
||||||
@@ -153,7 +153,7 @@ func (c *panelClient) addDomain(name string) (string, error) {
|
|||||||
// dkimRecord fetches a domain's page and scrapes the DKIM DNS record it tells
|
// dkimRecord fetches a domain's page and scrapes the DKIM DNS record it tells
|
||||||
// the administrator to publish.
|
// the administrator to publish.
|
||||||
func (c *panelClient) dkimRecord(domainID string) (name, value string, err error) {
|
func (c *panelClient) dkimRecord(domainID string) (name, value string, err error) {
|
||||||
_, body, err := c.get("/domains/" + domainID)
|
_, body, err := c.get("/outbound/domains/" + domainID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
@@ -172,7 +172,7 @@ func (c *panelClient) dkimRecord(domainID string) (name, value string, err error
|
|||||||
// login/password the panel renders inline (security.md — never recoverable
|
// login/password the panel renders inline (security.md — never recoverable
|
||||||
// later, so this is the only place to read it).
|
// later, so this is the only place to read it).
|
||||||
func (c *panelClient) addApplication(domainID, login, mode, addresses string) (appLogin, password string, err error) {
|
func (c *panelClient) addApplication(domainID, login, mode, addresses string) (appLogin, password string, err error) {
|
||||||
resp, body, err := c.postForm("/domains/"+domainID+"/applications", url.Values{
|
resp, body, err := c.postForm("/outbound/domains/"+domainID+"/applications", url.Values{
|
||||||
"login": {login},
|
"login": {login},
|
||||||
"mode": {mode},
|
"mode": {mode},
|
||||||
"addresses": {addresses},
|
"addresses": {addresses},
|
||||||
@@ -195,7 +195,7 @@ func (c *panelClient) addApplication(domainID, login, mode, addresses string) (a
|
|||||||
}
|
}
|
||||||
|
|
||||||
// setRateLimit saves a level-2 limit (guide § Rate limiting) on an application
|
// setRateLimit saves a level-2 limit (guide § Rate limiting) on an application
|
||||||
// or domain (/applications/{id}/ratelimit or /domains/{id}/ratelimit).
|
// or domain (/applications/{id}/ratelimit or /outbound/domains/{id}/settings/ratelimit).
|
||||||
func (c *panelClient) setRateLimit(path string, maxMessages, windowSeconds int) error {
|
func (c *panelClient) setRateLimit(path string, maxMessages, windowSeconds int) error {
|
||||||
vals := url.Values{
|
vals := url.Values{
|
||||||
"max_messages": {fmt.Sprintf("%d", maxMessages)},
|
"max_messages": {fmt.Sprintf("%d", maxMessages)},
|
||||||
@@ -211,25 +211,25 @@ func (c *panelClient) setRateLimit(path string, maxMessages, windowSeconds int)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendLogRows returns the raw /deliveries/rows HTML fragment, filtered to one
|
// sendLogRows returns the raw /outbound/log/fragment HTML fragment, filtered to one
|
||||||
// domain and optionally to one application login, for polling a row's status
|
// domain and optionally to one application login, for polling a row's status
|
||||||
// without parsing full HTML into structs. The application is a filter rather
|
// without parsing full HTML into structs. The application is a filter rather
|
||||||
// than something to search the returned rows for: the log's columns identify a
|
// than something to search the returned rows for: the log's columns identify a
|
||||||
// message (time, from, to, subject, status) and the sending application is only
|
// message (time, from, to, subject, status) and the sending application is only
|
||||||
// named on a row's own /deliveries/{id} page.
|
// named on a row's own /outbound/log/{id} page.
|
||||||
func (c *panelClient) sendLogRows(domain, app string) (string, error) {
|
func (c *panelClient) sendLogRows(domain, app string) (string, error) {
|
||||||
q := url.Values{"domain": {domain}}
|
q := url.Values{"domain": {domain}}
|
||||||
if app != "" {
|
if app != "" {
|
||||||
q.Set("app", app)
|
q.Set("app", app)
|
||||||
}
|
}
|
||||||
_, body, err := c.get("/deliveries/rows?" + q.Encode())
|
_, body, err := c.get("/outbound/log/fragment?" + q.Encode())
|
||||||
return body, err
|
return body, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// status fetches the authenticated landing page — used after a container
|
// status fetches the authenticated landing page — used after a container
|
||||||
// restart to confirm the session cookie is still accepted (plan C.4 check 8).
|
// restart to confirm the session cookie is still accepted (plan C.4 check 8).
|
||||||
func (c *panelClient) status() (*http.Response, error) {
|
func (c *panelClient) status() (*http.Response, error) {
|
||||||
resp, _, err := c.get("/status")
|
resp, _, err := c.get("/overview")
|
||||||
return resp, err
|
return resp, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,7 +240,7 @@ func (c *panelClient) status() (*http.Response, error) {
|
|||||||
// reordering the block's controls does not break the scrape; only the login
|
// reordering the block's controls does not break the scrape; only the login
|
||||||
// heading itself is anchored on.
|
// heading itself is anchored on.
|
||||||
func (c *panelClient) applicationID(domainID, login string) (string, error) {
|
func (c *panelClient) applicationID(domainID, login string) (string, error) {
|
||||||
_, body, err := c.get("/domains/" + domainID)
|
_, body, err := c.get("/outbound/domains/" + domainID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
var wantRunning = []string{"opendkim", "panel", "postfix", "cert-reload", "logrotate"}
|
var wantRunning = []string{"opendkim", "panel", "postfix", "cert-reload", "logrotate"}
|
||||||
|
|
||||||
// checkSupervisorProcesses shells into the container directly (not through the
|
// checkSupervisorProcesses shells into the container directly (not through the
|
||||||
// panel's /status page) so it works before an administrator account even
|
// panel's /overview page) so it works before an administrator account even
|
||||||
// exists — this is the first thing the harness checks after `docker compose
|
// exists — this is the first thing the harness checks after `docker compose
|
||||||
// up` (plan C.4). Programs take a moment to leave STARTING right after the
|
// up` (plan C.4). Programs take a moment to leave STARTING right after the
|
||||||
// container starts, so this polls rather than checking once.
|
// container starts, so this polls rather than checking once.
|
||||||
|
|||||||
Reference in New Issue
Block a user