Files
selfpost/internal/web/handlers/handlers_inbound.go
T
mixandClaude Opus 5.5 bd746c61d7 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>
2026-10-09 21:51:55 +03:00

363 lines
11 KiB
Go

package handlers
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
"github.com/mixeme/selfpost/internal/web/validate"
)
type inboundRow struct {
store.InboundDomain
DNS health.Status
Upstream string
TLSLabel string
RcptLabel string
}
// Inbound is delegated to domain administrators separately from outbound (plan
// § Who sees what), so its handlers come in three strengths:
//
// - requireInbound the list: anyone who reaches at least one inbound
// domain — and the list itself is filtered;
// - requireInboundDomain one domain (its page, DNS re-check, upstream,
// recipients): the global role, or a domain user
// this very inbound domain is assigned to;
// - requireInboundGlobal adding and deleting a domain: the global role only.
//
// All three answer 404 — for a missing domain, for another tenant's, and for a
// user with no inbound reach alike — so the panel never confirms what exists.
func (h *Handlers) requireInbound(w http.ResponseWriter, r *http.Request) (auth.Principal, bool) {
if !h.cfg.InboundEnabled || h.inbound == nil {
http.NotFound(w, r)
return auth.Principal{}, false
}
p, ok := h.principal(r)
if !ok || !p.HasInbound() {
http.NotFound(w, r)
return auth.Principal{}, false
}
return p, true
}
func (h *Handlers) requireInboundGlobal(w http.ResponseWriter, r *http.Request) (auth.Principal, bool) {
if !h.cfg.InboundEnabled || h.inbound == nil {
http.NotFound(w, r)
return auth.Principal{}, false
}
return h.requireGlobal(w, r)
}
// requireInboundDomain resolves the {id} of the request to an inbound domain
// the principal may work with.
func (h *Handlers) requireInboundDomain(w http.ResponseWriter, r *http.Request) (store.InboundDomain, bool) {
p, ok := h.requireInbound(w, r)
if !ok {
return store.InboundDomain{}, false
}
d, ok := h.lookupInbound(w, r)
if !ok {
return store.InboundDomain{}, false
}
if !p.CanAccessInboundDomain(d.ID) {
http.NotFound(w, r)
return store.InboundDomain{}, false
}
return d, true
}
// assignedInboundDomains lists the inbound domains a principal reaches. The
// filter is in the query (ListInboundDomainsForUser), not in the template.
func (h *Handlers) assignedInboundDomains(p auth.Principal) ([]store.InboundDomain, error) {
if p.IsGlobal() {
return h.inbound.List()
}
return h.store.ListInboundDomainsForUser(p.ID)
}
func (h *Handlers) lookupInbound(w http.ResponseWriter, r *http.Request) (store.InboundDomain, bool) {
if h.inbound == nil {
http.NotFound(w, r)
return store.InboundDomain{}, false
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 {
http.NotFound(w, r)
return store.InboundDomain{}, false
}
d, err := h.inbound.Get(id)
if err != nil {
if errors.Is(err, store.ErrInboundDomainNotFound) {
http.NotFound(w, r)
return store.InboundDomain{}, false
}
logf("panel: get inbound domain %d: %v", id, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return store.InboundDomain{}, false
}
return d, true
}
// HandleInboundList is the inbound-relay domain list: every inbound domain for
// the global role, the assigned ones for a domain administrator.
func (h *Handlers) HandleInboundList(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireInbound(w, r); !ok {
return
}
h.renderInboundList(w, r, http.StatusOK, "", "")
}
func (h *Handlers) renderInboundList(w http.ResponseWriter, r *http.Request, status int, formErr, formName string) {
p, _ := h.principal(r)
list, err := h.assignedInboundDomains(p)
if err != nil {
logf("panel: inbound list: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
data := h.pageBase(r)
data["Title"] = "SelfPost — inbound"
data["Active"] = "inbound"
data["Domains"] = h.inboundRows(list)
data["Error"] = formErr
data["FormName"] = formName
if r.URL.Query().Get("deleted") != "" {
data["Flash"] = "Inbound domain deleted."
}
h.view.Render(w, status, "inbound", data)
}
func (h *Handlers) inboundRows(domains []store.InboundDomain) []inboundRow {
rows := make([]inboundRow, len(domains))
var wg sync.WaitGroup
for i, d := range domains {
rows[i] = inboundRow{
InboundDomain: d,
DNS: health.StatusUnknown,
Upstream: inboundUpstream(d),
TLSLabel: tlsLabel(d.TLSMode),
RcptLabel: rcptLabel(d),
}
wg.Add(1)
go func() {
defer wg.Done()
if h.dns != nil && h.cfg.Hostname != "" {
rows[i].DNS = h.dns.InboundMX(d.Name, h.cfg.Hostname, false).Status
}
}()
}
wg.Wait()
return rows
}
func inboundUpstream(d store.InboundDomain) string {
if d.Host == "" {
return "—"
}
return fmt.Sprintf("%s:%d", d.Host, d.Port)
}
func tlsLabel(mode string) string {
switch mode {
case store.TLSModeEncrypt:
return "required"
case store.TLSModeNone:
return "off"
default:
return "opportunistic"
}
}
func tlsStatusClass(mode string) string {
if mode == store.TLSModeEncrypt {
return "ok"
}
return "unknown"
}
func rcptLabel(d store.InboundDomain) string {
if d.RecipientMode == store.RecipientModeAny {
return "any"
}
n := d.RecipientCount
if n == 1 {
return "1 listed"
}
return fmt.Sprintf("%d listed", n)
}
// HandleAddInbound validates the name, creates the inbound domain, and
// redirects to its page.
func (h *Handlers) HandleAddInbound(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireInboundGlobal(w, r); !ok {
return
}
if err := r.ParseForm(); err != nil {
h.renderInboundList(w, r, http.StatusBadRequest, "Invalid form submission.", "")
return
}
raw := r.PostFormValue("name")
name := validate.NormalizeDomain(raw)
if err := validate.Domain(name); err != nil {
h.renderInboundList(w, r, http.StatusBadRequest, err.Error(), raw)
return
}
d, err := h.inbound.Add(name)
if err != nil {
if errors.Is(err, store.ErrInboundDomainExists) {
h.renderInboundList(w, r, http.StatusConflict, "That inbound domain is already configured.", raw)
return
}
logf("panel: add inbound domain %q: %v", name, err)
h.renderInboundList(w, r, http.StatusInternalServerError,
"Could not add the domain. Please check the logs and try again.", raw)
return
}
http.Redirect(w, r, fmt.Sprintf("/inbound/domains/%d", d.ID), http.StatusSeeOther)
}
func (h *Handlers) HandleInboundDetail(w http.ResponseWriter, r *http.Request) {
d, ok := h.requireInboundDomain(w, r)
if !ok {
return
}
h.renderInboundDetail(w, r, http.StatusOK, d, inboundDetailView{})
}
type inboundDetailView struct {
FormErr string
TransportErr string
RecipientErr string
}
func (h *Handlers) renderInboundDetail(w http.ResponseWriter, r *http.Request, status int, d store.InboundDomain, extra inboundDetailView) {
mx := dnscheck.Result{Status: health.StatusUnknown}
if h.dns != nil && h.cfg.Hostname != "" {
mx = h.dns.InboundMX(d.Name, h.cfg.Hostname, false)
}
data := h.pageBase(r)
data["Title"] = "SelfPost — " + d.Name
data["Active"] = "inbound"
data["Domain"] = d
data["MX"] = mx
data["MXValue"] = "10 " + strings.TrimSuffix(h.cfg.Hostname, ".") + "."
data["Hostname"] = h.cfg.Hostname
data["TLSLabel"] = tlsLabel(d.TLSMode)
data["TLSClass"] = tlsStatusClass(d.TLSMode)
data["RecipientText"] = strings.Join(d.Recipients, "\n")
data["Flash"] = inboundFlash(r)
data["FormErr"] = extra.FormErr
data["TransportErr"] = extra.TransportErr
data["RecipientErr"] = extra.RecipientErr
h.view.Render(w, status, "inbound_domain", data)
}
func inboundFlash(r *http.Request) string {
switch {
case r.URL.Query().Get("saved") != "":
return "Upstream saved."
case r.URL.Query().Get("recipients") != "":
return "Recipients saved."
case r.URL.Query().Get("rechecked") != "":
return "DNS re-checked."
default:
return ""
}
}
func (h *Handlers) HandleInboundDNSRecheck(w http.ResponseWriter, r *http.Request) {
d, ok := h.requireInboundDomain(w, r)
if !ok {
return
}
if h.dns != nil && h.cfg.Hostname != "" {
h.dns.InboundMX(d.Name, h.cfg.Hostname, true)
}
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) {
d, ok := h.requireInboundDomain(w, r)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
h.renderInboundDetail(w, r, http.StatusBadRequest, d, inboundDetailView{TransportErr: "Invalid form submission."})
return
}
host := r.PostFormValue("host")
port := r.PostFormValue("port")
tlsMode := r.PostFormValue("tls_mode")
if err := h.inbound.SetTransport(d.ID, host, port, tlsMode); err != nil {
h.renderInboundDetail(w, r, http.StatusBadRequest, d, inboundDetailView{TransportErr: err.Error()})
return
}
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) {
d, ok := h.requireInboundDomain(w, r)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
h.renderInboundDetail(w, r, http.StatusBadRequest, d, inboundDetailView{RecipientErr: "Invalid form submission."})
return
}
mode := r.PostFormValue("recipient_mode")
addrs := splitAddresses(r.PostFormValue("addresses"))
if err := h.inbound.SetRecipients(d.ID, mode, addrs); err != nil {
h.renderInboundDetail(w, r, http.StatusBadRequest, d, inboundDetailView{RecipientErr: err.Error()})
return
}
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) {
if _, ok := h.requireInboundGlobal(w, r); !ok {
return
}
d, ok := h.lookupInbound(w, r)
if !ok {
return
}
data := h.pageBase(r)
data["Title"] = "SelfPost — delete " + d.Name
data["Active"] = "inbound"
data["Domain"] = d
data["Upstream"] = inboundUpstream(d)
h.view.Render(w, http.StatusOK, "inbound_delete", data)
}
func (h *Handlers) HandleInboundDelete(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireInboundGlobal(w, r); !ok {
return
}
d, ok := h.lookupInbound(w, r)
if !ok {
return
}
if h.dns != nil {
h.dns.Forget(d.Name)
}
if err := h.inbound.Delete(d.ID); err != nil {
if errors.Is(err, store.ErrInboundDomainNotFound) {
http.NotFound(w, r)
return
}
logf("panel: delete inbound domain %d: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/inbound/domains?deleted=1", http.StatusSeeOther)
}