Files
selfpost/internal/web/handlers/handlers_domains.go
T
mixandClaude Opus 5.5 2b8e0c086d
test / test (push) Canceled after 0s
audit 4: a deleted domain says so, the Outbound log filters by status and day
L17: past DeleteDomain both rebuilds run, their errors joined under
domain.ErrDeletedIncomplete; the key is removed only when OpenDKIM's tables
were rebuilt (they may still name it otherwise); the domain list says the
domain is deleted and that Reload configuration finishes it, not "internal
error". Tests in domain (the tables failing, the sender map failing) and
handlers.

The Outbound log's status and day filters (owner, 2026-10-13; the mockup
first, 80e3979): view.OutLogFilter carried by the poll, the pages, the rows'
links and the message's way back; SendLogFilter.Status, Since, Until (whole
UTC days, as the Inbound log's); rejected is the three refusals together; a
status not in the list is no narrowing. Tests: the filters through the
handler, the poll and the way back; the day boundaries in store.

The plan's milter-socket step is ticked: done in 2.5.2 (98e09fe).

Tested on the test server: go vet + go test of 25 packages, make e2e (all
pass, 642 s) on this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:34:24 +03:00

245 lines
8.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handlers
import (
"errors"
"fmt"
"net/http"
"strconv"
"sync"
"time"
"github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/health"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/validate"
"github.com/mixeme/selfpost/internal/web/view"
)
// HandleDashboard is the list of sending domains with the verdict of each DNS
// check, their application counts and the mail of the statistics window, plus —
// for the global role — the add-domain form (product.md).
func (h *Handlers) HandleDashboard(w http.ResponseWriter, r *http.Request) {
h.renderDashboard(w, r, http.StatusOK, "", "")
}
func (h *Handlers) renderDashboard(w http.ResponseWriter, r *http.Request, status int, formErr, formName string) {
p, ok := h.principal(r)
if !ok {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
domains, err := h.assignedDomains(p)
if err != nil {
logf("panel: domains: list domains: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
retention := h.sendLogRetentionDays()
_, _, days := store.StatsWindow(retention, time.Now())
page := view.NewOutDomains(h.shellMeta(r), p.IsGlobal(), days).
WithRows(h.outDomainRows(domains, retention, p.IsGlobal())).
WithResult(dashboardFlash(r), formErr, formName)
h.view.Render(w, status, "out-domains", page)
}
// outDomainRows is one row per domain: the verdict of each DNS check (cached for
// a few minutes, as on the domain page), the selector, the applications and the
// mail of the statistics window. The checks run side by side.
func (h *Handlers) outDomainRows(domains []store.Domain, retention int, canDelete bool) []view.OutDomainRow {
rows := make([]view.OutDomainRow, len(domains))
var wg sync.WaitGroup
for i, d := range domains {
rows[i] = view.OutDomainRow{
Name: d.Name, Href: view.DomainHref(d.ID), Selector: d.DKIMSelector, Apps: d.AppCount,
DNS: dnsTags(health.StatusUnknown, health.StatusUnknown, health.StatusUnknown), Activity: "—",
}
if canDelete {
rows[i].DeleteHref = view.DomainHref(d.ID) + "/delete"
}
wg.Add(1)
go func() {
defer wg.Done()
if stats, err := h.store.DomainSendStats(d.Name, retention, d.CreatedAt); err != nil {
logf("panel: domains: domain %d: send stats: %v", d.ID, err)
} else {
rows[i].Activity = view.FormatActivity(stats.Total, stats.PeakPerHour)
}
record, err := h.domains.DKIMRecord(d)
if err != nil {
logf("panel: domains: domain %d: dkim record: %v", d.ID, err)
return
}
dns, _ := h.domainDNS(d, record, d.DMARCRua, false)
rows[i].DNS = dnsTags(dns.DKIM.Status, dns.SPF.Status, dns.DMARC.Status)
}()
}
wg.Wait()
return rows
}
func dashboardFlash(r *http.Request) string {
if r.URL.Query().Get("deleted") != "" {
return "Domain deleted."
}
return ""
}
// HandleAddDomain validates the submitted name, creates the domain (DKIM key +
// OpenDKIM reload), and redirects to the domain's page so the DNS record to
// publish is shown (product.md).
func (h *Handlers) HandleAddDomain(w http.ResponseWriter, r *http.Request) {
_, ok := h.requireGlobal(w, r)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
h.renderDashboard(w, r, http.StatusBadRequest, "Invalid form submission.", "")
return
}
raw := r.PostFormValue("name")
name := validate.NormalizeDomain(raw)
if err := validate.Domain(name); err != nil {
h.renderDashboard(w, r, http.StatusBadRequest, err.Error(), raw)
return
}
d, err := h.domains.Add(name)
if err != nil {
if errors.Is(err, store.ErrDomainExists) {
h.renderDashboard(w, r, http.StatusConflict, "That domain is already configured.", raw)
return
}
logf("panel: add domain %q: %v", name, err)
h.renderDashboard(w, r, http.StatusInternalServerError,
"Could not add the domain. Please check the logs and try again.", raw)
return
}
http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d", d.ID), http.StatusSeeOther)
}
// HandleDeleteConfirm shows the cascade warning before a domain is removed.
func (h *Handlers) HandleDeleteConfirm(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
d, ok := h.lookupDomain(w, r)
if !ok {
return
}
apps, err := h.store.ListApplicationsByDomain(d.ID)
if err != nil {
logf("panel: delete domain %d: list applications: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
logins := make([]string, len(apps))
for i, a := range apps {
logins[i] = a.Login
}
h.view.Render(w, http.StatusOK, "out-domain-delete", view.NewOutDomainDelete(h.shellMeta(r), d.ID, d.Name, logins))
}
// HandleDeleteDomain performs the deletion and returns to the domain list.
func (h *Handlers) HandleDeleteDomain(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
id, ok := parseDomainID(w, r)
if !ok {
return
}
name := ""
if d, err := h.domains.Get(id); err == nil {
name = d.Name
defer h.dns.Forget(d.Name)
}
if err := h.domains.Delete(id); err != nil {
if errors.Is(err, store.ErrDomainNotFound) {
http.NotFound(w, r)
return
}
logf("panel: delete domain %d: %v", id, err)
if errors.Is(err, domain.ErrDeletedIncomplete) {
h.renderDashboard(w, r, http.StatusInternalServerError,
"Domain "+name+" deleted, but the mail configuration was not fully rewritten after it. Run Reload configuration on Server › Health; the panel's log says what failed.", "")
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/outbound/domains?deleted=1", http.StatusSeeOther)
}
// HandleReload writes again, from the database, every file the panel keeps
// for the mail path and has OpenDKIM and Postfix re-read them - what the
// first start after a restore does (cmd/panel mailStack.Resync): OpenDKIM's
// tables, the sender map, with the inbound relay on its maps and the filter
// lists, with DMARC reports on their maps (architecture.md § Panel HTTP
// surface). Before 2.5.4 it left the last two out, and said "regenerated"
// over them (audit 2026-10-12, L13). A failure is a message on Health that
// names the step; the log has the error.
func (h *Handlers) HandleReload(w http.ResponseWriter, r *http.Request) {
if _, ok := h.requireGlobal(w, r); !ok {
return
}
type step struct {
name string
run func() error
}
steps := []step{
{"OpenDKIM's tables", h.domains.Resync},
{"the sender map", h.apps.Resync},
}
if h.cfg.InboundEnabled && h.inbound != nil {
steps = append(steps, step{"the inbound relay maps", h.inbound.Resync})
}
if h.cfg.InboundEnabled && h.lists != nil {
steps = append(steps, step{"the filter lists", h.lists.Sync})
}
if h.dmarc != nil && h.dmarc.Enabled() {
steps = append(steps, step{"the DMARC report maps", h.dmarc.Resync})
}
for _, s := range steps {
if err := s.run(); err != nil {
logf("panel: manual reload (%s): %v", s.name, err)
page := h.healthView(r, "")
page.Flash = &view.Flash{Error: true, Text: view.Plain("Reload failed at " + s.name + "; what came before it was written and re-read. The panel's log says why.")}
h.view.Render(w, http.StatusInternalServerError, "health", page)
return
}
}
http.Redirect(w, r, "/server/health?reloaded=1", http.StatusSeeOther)
}
func (h *Handlers) lookupDomain(w http.ResponseWriter, r *http.Request) (store.Domain, bool) {
id, ok := parseDomainID(w, r)
if !ok {
return store.Domain{}, false
}
d, err := h.domains.Get(id)
if err != nil {
if errors.Is(err, store.ErrDomainNotFound) {
http.NotFound(w, r)
return store.Domain{}, false
}
logf("panel: get domain %d: %v", id, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return store.Domain{}, false
}
p, ok := h.principal(r)
if !ok || !p.CanAccessDomain(d.ID) {
http.NotFound(w, r)
return store.Domain{}, false
}
return d, true
}
func parseDomainID(w http.ResponseWriter, r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 {
http.NotFound(w, r)
return 0, false
}
return id, true
}