panel-redesign: Outbound domains, domain, settings, application on the kit

Stage 2, step 3. Written by Sonnet, reviewed by Opus.

  - Six pages named after their mockups: out-domains, out-domain,
    out-domain-settings, out-app, out-app-created, out-domain-delete,
    with typed page data (view/outbound.go, outbound_app.go), fixtures
    and tests. The engine pages dashboard, domain_detail and
    domain_delete are gone with their templates and are off
    legacy_pages.txt. GET .../settings has its own handler.
  - Handlers pass typed data; routes, POST targets, field names,
    validation, authorization (both roles, another tenant's id is a 404)
    and Cache-Control: no-store on the password page are unchanged.
  - Kit, attribute only: Action.Confirm writes data-confirm on a POST
    action's form (panel.js already reads it).
  - test/e2e/panel_client.go scrapes the new markup (copy fields, the
    application row's Edit link). Not run: no Docker here.

Fixes found on the way
  - Report address of a domain that follows another user's default: the
    select had one "inherit" option, selected, so saving the form
    untouched moved the domain to whoever saved it. It now offers that
    user's default as "keep" (selected, saves nothing) and "My default
    instead" as the change, as the mockup draws it; "keep" on a domain
    that does not follow someone else is refused.
  - panel.js: syncShowWhen stopped at the first matching rule, so the
    auto-limit fields were never toggled with the limit mode. Every
    matching rule applies now.

Differences from the mockups, for the owner:
  - application rows carry Edit only. New password and Delete are POSTs,
    and the kit has no link-styled POST control for a table row; both are
    in the application page's head with a confirmation. Drawing them in
    the row needs a design: change (as Sign out was);
  - the application login is read-only on edit (the handler refuses a
    rename); the submit says Create application on the empty form;
  - multiplier bounds are the store's 1.5-5, default 2.5;
  - "Recalculate" buttons exist where the stored limit is automatic;
  - a domain administrator has no add box and no Delete; Delete domain is
    global-only on the domain page and on settings;
  - a fourth record, Report authorization, appears when the report
    address is on another domain; record states missing / weak / not
    checked are tags with the explanation under the value;
  - refusals are the kit flash above the first box, and those of the
    settings forms now show on the settings page; successful saves still
    redirect to the domain page;
  - the export checkbox is ticked by default, as drawn.

Evidence
  - docs/assets/panel-redesign/evidence/out-domains.png, out-domain.png,
    out-domain-settings.png, out-app.png, out-app-created.png,
    out-domain-delete.png: 1440 px wide, headless Edge, real templates
    served locally with their fixtures - NOT from the container (no
    Docker on this machine). Taken before the report-address fix, which
    does not change what the fixture renders.
  - panel/audit.js on the six pages at 1440 and 1280 (text inside
    [hidden] blocks skipped): 866 texts checked, problems: [].
  - go test -count=1 ./internal/web/...
      ok  internal/web
      ok  internal/web/auth
      ok  internal/web/handlers
      ok  internal/web/validate
      ok  internal/web/view

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-10 00:49:20 +03:00
co-authored by Claude Opus 5.5
parent 5c654723c2
commit acdfaaecd7
37 changed files with 2767 additions and 1010 deletions
+356 -236
View File
@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@@ -14,74 +15,24 @@ import (
"github.com/mixeme/selfpost/internal/domain"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/validate"
"github.com/mixeme/selfpost/internal/web/view"
)
// newCred carries a freshly generated login/password to the template so it can
// be shown exactly once (security.md). It is never read back from storage.
type newCred struct {
Login string
Password string
}
// detailView holds the one-shot, request-specific extras layered on top of a
// domain's persistent state when rendering its page: an application-form error,
// the values to repopulate that form, and any just-issued credential to show
// once.
type detailView struct {
FormErr string
FormLogin string
FormMode string
FormAddrs string
NewCred *newCred
// RateLimitErr surfaces a validation error from a domain- or
// application-level rate-limit form (guide § Rate limiting) as a page
// banner.
RateLimitErr string
// ExportErr surfaces a rejected encryption password from the export card.
ExportErr string
}
// appRateLimitView pairs an application with its differentiated rate-limit
// settings for the domain page. store.Application is embedded so the existing
// template fields (Login, AddressMode, Addresses, ID) resolve unchanged.
type appRateLimitView struct {
store.Application
HasLimit bool
AuthIPsText string
MaxText string
WindowVal string
Mode string
AutoMultiplier string
AutoUpdated string
IsAuto bool
// RLMode is the form's limit choice: "domain" (no limit of its own),
// "manual" or "auto".
RLMode string
Stats sendStatsView
}
// sendStatsView is the template-facing send statistics block.
type sendStatsView struct {
Total int64
PeakPerHour int64
AvgPerHour string
}
// HandleDomainDetail shows a single domain: its DKIM DNS record (product.md)
// and its applications with the controls to add, edit, delete and re-issue
// credentials (product.md).
// HandleDomainDetail shows a single domain: the DNS records it must publish and
// what DNS says about them, its applications and how to connect (product.md).
func (h *Handlers) HandleDomainDetail(w http.ResponseWriter, r *http.Request) {
d, ok := h.lookupDomain(w, r)
if !ok {
return
}
h.renderDomainDetail(w, r, http.StatusOK, d, detailView{FormMode: store.AddressModeWildcard})
h.renderDomain(w, r, http.StatusOK, d)
}
// renderDomainDetail renders the domain page. view supplies request-specific
// extras (form error/values, a one-time credential); everything else is loaded
// fresh from the stores so the page always reflects committed state.
func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, status int, d store.Domain, view detailView) {
// renderDomain renders the domain page. Everything is loaded fresh from the
// stores, so the page always reflects committed state; the DNS state is cached
// by the checker, so re-rendering after a redirect costs nothing.
func (h *Handlers) renderDomain(w http.ResponseWriter, r *http.Request, status int, d store.Domain) {
p, _ := h.principal(r)
record, err := h.domains.DKIMRecord(d)
if err != nil {
logf("panel: domain %d: dkim record: %v", d.ID, err)
@@ -94,49 +45,16 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
appViews := make([]appRateLimitView, 0, len(apps))
retention := h.sendLogRetentionDays()
rows := make([]view.OutAppRow, 0, len(apps))
for _, a := range apps {
rl, ok, err := h.apps.RateLimit(a.ID)
row, err := h.outAppRow(d, a, retention)
if err != nil {
logf("panel: application %d: rate limit: %v", a.ID, err)
logf("panel: application %d: %v", a.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
appStats, err := h.store.AppSendStats(a.Login, retention, a.CreatedAt)
if err != nil {
logf("panel: application %d: send stats: %v", a.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
mode := store.RateLimitModeManual
if ok && rl.Mode != "" {
mode = rl.Mode
}
mult := rl.AutoMultiplier
if mult <= 0 {
mult = store.DefaultAutoMultiplier
}
appViews = append(appViews, appRateLimitView{
Application: a,
HasLimit: ok && rl.Active(),
AuthIPsText: strings.Join(a.AuthAllowedIPs, "\n"),
MaxText: intOrBlank(rl.MaxMessages),
WindowVal: windowOrDefault(rl.WindowSeconds),
Mode: mode,
AutoMultiplier: formatMultiplier(mult),
IsAuto: ok && rl.IsAuto(),
RLMode: limitChoice(rl, ok),
AutoUpdated: formatAutoUpdated(rl.AutoUpdatedAt),
Stats: formatSendStats(appStats),
})
}
domainRL, domainRLok, err := h.domains.RateLimit(d.ID)
if err != nil {
logf("panel: domain %d: rate limit: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
rows = append(rows, row)
}
domainStats, err := h.store.DomainSendStats(d.Name, retention, d.CreatedAt)
if err != nil {
@@ -144,20 +62,15 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
domainMode := store.RateLimitModeManual
domainMult := store.DefaultAutoMultiplier
if domainRLok {
if domainRL.Mode != "" {
domainMode = domainRL.Mode
}
if domainRL.AutoMultiplier > 0 {
domainMult = domainRL.AutoMultiplier
}
domainRL, domainRLok, err := h.domains.RateLimit(d.ID)
if err != nil {
logf("panel: domain %d: rate limit: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// What DNS actually publishes for the domain today, checked against the key
// this server signs with. Cached by the checker, so re-rendering the page
// after a form post costs nothing.
// this server signs with.
reportEmail, err := h.domainReportAddress(d)
if err != nil {
logf("panel: domain %d: dmarc report address: %v", d.ID, err)
@@ -165,98 +78,217 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
return
}
dns, srv := h.domainDNS(d, record, reportEmail, false)
reportAuthName, reportAuthValue, needsReportAuth := dnscheck.ExternalReportAuth(d.Name, reportEmail)
hostedAddr := h.hostedDMARCAddress(d.Name)
// The form's "inherit" is a domain that follows a user's default. What it
// offers next to that choice is the address following would give: the
// followed user's when the domain already follows one, otherwise the
// signed-in user's own — choosing it makes the domain follow them.
dmarcMode := "inherit"
dmarcCustom := ""
profileEmail := reportEmail
if !d.DMARCRuaUserID.Valid {
profileEmail = ""
if p, ok := h.principal(r); ok {
if def, err := h.store.GetDMARCDefault(p.ID); err == nil {
profileEmail = def.Resolve(hostedAddr)
}
}
switch {
case d.DMARCRua == "":
dmarcMode = "none"
case hostedAddr != "" && strings.EqualFold(d.DMARCRua, hostedAddr):
dmarcMode = "hosted"
default:
dmarcMode = "custom"
dmarcCustom = d.DMARCRua
}
records := []view.Record{
view.DKIMRecord(d.DKIMSelector, record.Name, record.Value, dnsCheck(dns.DKIM)),
view.SPFRecord(d.Name, dnscheck.SPFExample(h.cfg.Hostname, srv.IPs), dnsCheck(dns.SPF)),
}
dmarcSource := "policy"
switch {
case dmarcMode == "hosted":
dmarcSource = "hosted"
case dmarcMode == "custom":
dmarcSource = "custom"
case dmarcMode == "none":
dmarcSource = "none"
case reportEmail != "":
dmarcSource = "settings"
reports := ""
if h.cfg.DMARCEnabled && reportEmail != "" {
reports = "/outbound/dmarc/domains/" + strconv.FormatInt(d.ID, 10)
}
records = append(records, view.DMARCRecord(view.DMARCRecordInput{
Host: dnscheck.DMARCRecordName(d.Name), Value: dnscheck.DMARCExample(reportEmail), Check: dnsCheck(dns.DMARC),
Source: h.reportSource(d, reportEmail), SettingsHref: view.DomainHref(d.ID) + "/settings", ReportsHref: reports,
SameDomain: reportEmail != "" && strings.EqualFold(dnscheck.EmailDomain(reportEmail), d.Name) &&
!(h.cfg.DMARCEnabled && dmarc.IsHostedOnHostname(reportEmail, h.cfg.Hostname)),
}))
if name, value, needs := dnscheck.ExternalReportAuth(d.Name, reportEmail); needs {
records = append(records, view.ReportAuthRecord(name, value, dnsCheck(dns.DMARCReportAuth)))
}
data := h.pageBase(r)
data["Title"] = "SelfPost — " + d.Name
data["Active"] = "domains"
data["Domain"] = d
data["Record"] = record
data["DNS"] = dns
data["SPFExample"] = dnscheck.SPFExample(h.cfg.Hostname, srv.IPs)
data["DMARCName"] = dnscheck.DMARCRecordName(d.Name)
data["DMARCExample"] = dnscheck.DMARCExample(reportEmail)
data["DMARCSource"] = dmarcSource
data["ProfileDMARCEmail"] = profileEmail
data["ResolvedDMARCEmail"] = reportEmail
data["DMARCRuaMode"] = dmarcMode
data["DMARCRuaCustom"] = dmarcCustom
data["HostedDMARCEmail"] = hostedAddr
data["DMARCIngestEnabled"] = h.cfg.DMARCEnabled
data["ReportAuthName"] = reportAuthName
data["ReportAuthValue"] = reportAuthValue
data["NeedsReportAuth"] = needsReportAuth
data["SameDomainRUA"] = reportEmail != "" && strings.EqualFold(dnscheck.EmailDomain(reportEmail), d.Name) &&
!(h.cfg.DMARCEnabled && dmarc.IsHostedOnHostname(reportEmail, h.cfg.Hostname))
data["Hostname"] = h.cfg.Hostname
data["SubmissionEnabled"] = h.cfg.SubmissionEnabled
data["Apps"] = appViews
data["Error"] = view.FormErr
data["FormLogin"] = view.FormLogin
data["FormMode"] = view.FormMode
data["FormAddrs"] = view.FormAddrs
data["NewCred"] = view.NewCred
data["Flash"] = detailFlash(r)
data["Wildcard"] = store.AddressModeWildcard
data["List"] = store.AddressModeList
data["RateLimitErr"] = view.RateLimitErr
data["ExportErr"] = view.ExportErr
data["MinPwLen"] = validate.MinSecretFilePasswordLen
data["DomainHasRL"] = domainRLok && domainRL.Active()
data["DomainRLMax"] = intOrBlank(domainRL.MaxMessages)
data["DomainRLWin"] = windowOrDefault(domainRL.WindowSeconds)
data["DomainRLMaxNum"] = domainRL.MaxMessages
data["DomainRLMode"] = domainMode
data["DomainRLAuto"] = domainRLok && domainRL.IsAuto()
data["DomainRLMultiplier"] = formatMultiplier(domainMult)
data["DomainRLAutoUpdated"] = formatAutoUpdated(domainRL.AutoUpdatedAt)
data["DomainStats"] = formatSendStats(domainStats)
data["StatsWindowDays"] = domainStats.WindowDays
data["StatsRetentionWarning"] = retention < store.StatsWindowDays
data["L1Messages"] = h.l1Messages()
data["L1Window"] = h.l1Window()
data["DefaultAutoMultiplier"] = store.DefaultAutoMultiplier
data["MinAutoMultiplier"] = store.MinAutoMultiplier
data["MaxAutoMultiplier"] = store.MaxAutoMultiplier
h.view.Render(w, status, "domain_detail", data)
page := view.NewOutDomain(h.shellMeta(r), d.ID, d.Name, p.IsGlobal()).
WithStats(domainStats.Total, domainStats.PeakPerHour, fmt.Sprintf("%.1f", domainStats.AvgPerHour), domainStats.WindowDays).
WithRecords(records, view.FormatAge(dns.CheckedAt, time.Now())).
WithApplications(rows).
WithConnection(h.cfg.Hostname, h.cfg.SubmissionEnabled).
WithSeeAlso("/outbound/log?domain="+url.QueryEscape(d.Name), reports).
WithRateLimit(domainRLok && domainRL.IsAuto(), domainRLok && domainRL.Active() && !domainRL.IsAuto()).
WithResult(detailFlash(r))
h.view.Render(w, status, "out-domain", page)
}
// dnsCheck is a DNS result as the view reads it.
func dnsCheck(r dnscheck.Result) view.DNSCheck {
return view.DNSCheck{Status: string(r.Status), Detail: r.Detail, Found: r.Records}
}
// reportSource says in words where a domain's DMARC reports go: the default of
// the user it follows, the hosted address, an address of its own, or nowhere.
func (h *Handlers) reportSource(d store.Domain, reportEmail string) string {
switch {
case d.DMARCRuaUserID.Valid:
if def, err := h.store.GetDMARCDefault(d.DMARCRuaUserID.Int64); err == nil && def.Username != "" {
return def.Username + "'s default"
}
return "a user's default"
case reportEmail == "":
return "none"
case strings.EqualFold(reportEmail, h.hostedDMARCAddress(d.Name)):
return "SelfPost hosted"
}
return "a custom address"
}
// outAppRow is one line of the Applications table of a domain.
func (h *Handlers) outAppRow(d store.Domain, a store.Application, retention int) (view.OutAppRow, error) {
rl, ok, err := h.apps.RateLimit(a.ID)
if err != nil {
return view.OutAppRow{}, fmt.Errorf("rate limit: %w", err)
}
stats, err := h.store.AppSendStats(a.Login, retention, a.CreatedAt)
if err != nil {
return view.OutAppRow{}, fmt.Errorf("send stats: %w", err)
}
senders := a.Addresses
if a.AddressMode == store.AddressModeWildcard {
senders = []string{"*@" + d.Name}
}
limits := []view.Tag{{Label: "domain"}}
if ok && rl.Active() {
label := view.FormatRate(rl.MaxMessages, rl.WindowSeconds)
if rl.IsAuto() {
label = "auto · " + label
}
limits = []view.Tag{{Status: "ok", Label: label}}
}
if a.AuthIPRestrict {
label := strconv.Itoa(len(a.AuthAllowedIPs)) + " IPs"
if len(a.AuthAllowedIPs) == 1 {
label = "1 IP"
}
limits = append(limits, view.Tag{Status: "ok", Label: label})
}
return view.OutAppRow{
Login: a.Login, Senders: senders, Activity: view.FormatActivity(stats.Total, stats.PeakPerHour), Limits: limits,
Edit: fmt.Sprintf("%s/applications/%d", view.DomainHref(d.ID), a.ID),
}, nil
}
// settingsView is what a re-shown settings page carries besides the stored
// state: the refusal, and the report-address choice and custom address that
// were submitted (empty: show what is stored).
type settingsView struct {
Err string
RuaMode string
RuaCustom string
}
// HandleDomainSettings shows the settings that are set once and rarely
// touched: where DMARC reports go, the domain's rate limit, the export and the
// delete entry.
func (h *Handlers) HandleDomainSettings(w http.ResponseWriter, r *http.Request) {
d, ok := h.lookupDomain(w, r)
if !ok {
return
}
h.renderDomainSettings(w, r, http.StatusOK, d, settingsView{})
}
func (h *Handlers) renderDomainSettings(w http.ResponseWriter, r *http.Request, status int, d store.Domain, sv settingsView) {
p, _ := h.principal(r)
rl, ok, err := h.domains.RateLimit(d.ID)
if err != nil {
logf("panel: domain %d: rate limit: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
stats, err := h.store.DomainSendStats(d.Name, h.sendLogRetentionDays(), d.CreatedAt)
if err != nil {
logf("panel: domain %d: send stats: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
mult := rl.AutoMultiplier
if !ok || mult <= 0 {
mult = store.DefaultAutoMultiplier
}
rate := view.DomainRateLimit{
Active: ok && rl.Active(), Auto: ok && rl.IsAuto(),
MaxMessages: intOrBlank(rl.MaxMessages), Window: windowOrDefault(rl.WindowSeconds), Multiplier: formatMultiplier(mult),
Peak: stats.PeakPerHour, Updated: formatAutoUpdated(rl.AutoUpdatedAt),
L1Messages: h.l1Messages(), L1Window: h.l1Window(),
MinMultiplier: formatBound(store.MinAutoMultiplier), MaxMultiplier: formatBound(store.MaxAutoMultiplier),
DefaultMultiplier: formatBound(store.DefaultAutoMultiplier),
}
if rate.Auto {
rate.Computed = intOrBlank(rl.MaxMessages)
}
options, selected, custom, help := h.reportAddressForm(r, d)
if sv.RuaMode != "" {
selected, custom = sv.RuaMode, sv.RuaCustom
}
page := view.NewOutDomainSettings(h.shellMeta(r), d.ID, d.Name, d.AppCount, p.IsGlobal()).
WithReportAddress(options, selected, custom, help).
WithExport(validate.MinSecretFilePasswordLen).
WithRateLimit(rate).
WithResult("", sv.Err)
h.view.Render(w, status, "out-domain-settings", page)
}
// reportAddressForm is the choices of the report-address select for a domain
// and the signed-in user. "inherit" makes the domain follow the default of the
// user who saves the form (HandleDomainDMARC), so what the option says depends
// on whom the domain follows now: the user themselves, someone else, or nobody.
// A domain that follows someone else also offers "keep" — that user's default,
// selected — so saving the form untouched does not take the domain over.
func (h *Handlers) reportAddressForm(r *http.Request, d store.Domain) (options []view.Option, selected, custom string, help view.Text) {
hosted := h.hostedDMARCAddress(d.Name)
me, _ := h.principal(r)
mine, _ := h.store.GetDMARCDefault(me.ID)
address := func(a string) string {
if a == "" {
return "no report address yet"
}
return a
}
myAddr := address(mine.Resolve(hosted))
help = view.Rich("A default belongs to a user and is set under their ", view.Link("/account#dmarc", "Account"),
"; a domain follows one named user, so two people sharing a domain never pull it two ways. Changing this changes the DMARC record to publish.")
inherit := view.Option{Value: "inherit", Label: "My default — " + myAddr}
followsOther := d.DMARCRuaUserID.Valid && d.DMARCRuaUserID.Int64 != me.ID
switch {
case followsOther:
inherit.Label = "My default instead — " + myAddr + " (you are " + me.Username + ")"
keep := view.Option{Value: "keep", Label: "Another user's default"}
if other, err := h.store.GetDMARCDefault(d.DMARCRuaUserID.Int64); err == nil {
keep.Label = other.Username + "'s default — " + address(other.Resolve(hosted))
}
options = append(options, keep)
case d.DMARCRuaUserID.Valid:
inherit.Label = me.Username + "'s default — " + myAddr
options = append(options, inherit)
default:
options = append(options, inherit)
}
if hosted != "" {
options = append(options, view.Option{Value: "hosted", Label: "SelfPost hosted (" + hosted + ")"})
}
// The mockup's order: whose default is followed now, hosted, "mine instead".
if followsOther {
options = append(options, inherit)
}
options = append(options, view.Option{Value: "none", Label: "No aggregate reports"}, view.Option{Value: "custom", Label: "Custom address"})
switch {
case followsOther:
selected = "keep"
case d.DMARCRuaUserID.Valid:
selected = "inherit"
case d.DMARCRua == "":
selected = "none"
case hosted != "" && strings.EqualFold(d.DMARCRua, hosted):
selected = "hosted"
default:
selected, custom = "custom", d.DMARCRua
}
return options, selected, custom, help
}
// formatBound writes a multiplier bound the short way: "1.5", "5".
func formatBound(v float64) string { return strconv.FormatFloat(v, 'f', -1, 64) }
// domainDNS resolves what the world sees for a domain: its DKIM, SPF and DMARC
// records. The server's own address comes from the (separately
// cached) hostname check, so the SPF heuristic knows which IP it is looking for
@@ -329,14 +361,6 @@ func windowOrDefault(n int) string {
return strconv.Itoa(n)
}
func formatSendStats(s store.SendStats) sendStatsView {
return sendStatsView{
Total: s.Total,
PeakPerHour: s.PeakPerHour,
AvgPerHour: fmt.Sprintf("%.1f", s.AvgPerHour),
}
}
func formatMultiplier(v float64) string {
return strconv.FormatFloat(v, 'f', 1, 64)
}
@@ -429,14 +453,129 @@ func noStore(w http.ResponseWriter) {
w.Header().Set("Pragma", "no-cache")
}
// HandleApplicationNew shows the form that adds an application. Until the
// domain page is split (stage 2) the form is a card of that page.
// blankAppForm is the application form as it opens: any address of the domain,
// no client IPs, the domain's limit.
func blankAppForm() view.OutAppForm {
return view.OutAppForm{
Mode: store.AddressModeWildcard, LimitMode: view.LimitDomain,
Window: strconv.Itoa(defaultRateLimitWindowSeconds), Multiplier: formatMultiplier(store.DefaultAutoMultiplier),
}
}
// submittedAppForm reads the application form as it was posted, to show it
// again with its refusal and what was typed.
func submittedAppForm(r *http.Request) view.OutAppForm {
f := blankAppForm()
f.Login = strings.TrimSpace(r.PostFormValue("login"))
if mode := r.PostFormValue("mode"); mode != "" {
f.Mode = mode
}
f.Addresses = r.PostFormValue("addresses")
f.IPRestrict = r.PostFormValue("auth_ip_restrict") != ""
f.AllowedIPs = r.PostFormValue("auth_allowed_ips")
if mode := strings.TrimSpace(r.PostFormValue("rl_mode")); mode != "" {
f.LimitMode = mode
}
f.MaxMessages = r.PostFormValue("max_messages")
if window := r.PostFormValue("window_seconds"); window != "" {
f.Window = window
}
if mult := r.PostFormValue("auto_multiplier"); mult != "" {
f.Multiplier = mult
}
return f
}
// renderApplicationForm renders the application form: empty for a new
// application (a == nil), or the stored one. submitted, when set, replaces the
// fields with what was posted; formErr is the refusal shown above the form.
func (h *Handlers) renderApplicationForm(w http.ResponseWriter, r *http.Request, status int, d store.Domain,
a *store.Application, submitted *view.OutAppForm, formErr string) {
fail := func(what string, err error) {
logf("panel: domain %d: application form: %s: %v", d.ID, what, err)
http.Error(w, "internal error", http.StatusInternalServerError)
}
retention := h.sendLogRetentionDays()
domainRL, domainRLok, err := h.domains.RateLimit(d.ID)
if err != nil {
fail("domain rate limit", err)
return
}
rate := view.AppRateLimit{
L1Messages: h.l1Messages(), L1Window: h.l1Window(),
MinMultiplier: formatBound(store.MinAutoMultiplier), MaxMultiplier: formatBound(store.MaxAutoMultiplier),
DefaultMultiplier: formatBound(store.DefaultAutoMultiplier),
}
if domainRLok && domainRL.Active() {
rate.DomainLimit = view.FormatRate(domainRL.MaxMessages, domainRL.WindowSeconds)
}
var (
page *view.OutApp
form = blankAppForm()
state view.OutAppState
)
if a == nil {
page = view.NewOutApp(h.shellMeta(r), d.ID, d.Name, "")
} else {
rl, ok, err := h.apps.RateLimit(a.ID)
if err != nil {
fail("rate limit", err)
return
}
stats, err := h.store.AppSendStats(a.Login, retention, a.CreatedAt)
if err != nil {
fail("send stats", err)
return
}
mult := rl.AutoMultiplier
if mult <= 0 {
mult = store.DefaultAutoMultiplier
}
form = view.OutAppForm{
Login: a.Login, Mode: a.AddressMode, Addresses: strings.Join(a.Addresses, "\n"),
IPRestrict: a.AuthIPRestrict, AllowedIPs: strings.Join(a.AuthAllowedIPs, "\n"),
LimitMode: limitChoice(rl, ok), MaxMessages: intOrBlank(rl.MaxMessages), Window: windowOrDefault(rl.WindowSeconds),
Multiplier: formatMultiplier(mult),
}
state = view.OutAppState{IPs: a.AuthIPRestrict, Limit: ok && rl.Active(), Auto: ok && rl.IsAuto()}
rate.Peak = stats.PeakPerHour
if state.Auto {
rate.Computed, rate.Updated = intOrBlank(rl.MaxMessages), formatAutoUpdated(rl.AutoUpdatedAt)
}
page = view.NewOutApp(h.shellMeta(r), d.ID, d.Name, a.Login).
WithApplication(d.ID, a.ID, stats.Total, stats.PeakPerHour, fmt.Sprintf("%.1f", stats.AvgPerHour), stats.WindowDays)
}
if submitted != nil {
form = *submitted
if a != nil {
form.Login = a.Login // the login is not renamed by this form
}
}
h.view.Render(w, status, "out-app", page.WithForm(form, state, rate).WithResult("", formErr))
}
// renderPasswordOnce answers with the page that shows a password a single time:
// the login and the password, which are not stored, and how to connect. The
// response is the one place the password exists, so it is never cached.
func (h *Handlers) renderPasswordOnce(w http.ResponseWriter, r *http.Request, status int, d store.Domain,
a store.Application, password string, fresh bool) {
senders := a.Addresses
if a.AddressMode == store.AddressModeWildcard {
senders = []string{"*@" + d.Name}
}
noStore(w)
h.view.Render(w, status, "out-app-created", view.NewOutAppCreated(h.shellMeta(r), d.ID, d.Name, a.Login, password, fresh,
h.cfg.Hostname, h.cfg.SubmissionEnabled, senders))
}
// HandleApplicationNew shows the form that adds an application.
func (h *Handlers) HandleApplicationNew(w http.ResponseWriter, r *http.Request) {
d, ok := h.lookupDomain(w, r)
if !ok {
return
}
h.renderDomainDetail(w, r, http.StatusOK, d, detailView{FormMode: store.AddressModeWildcard})
h.renderApplicationForm(w, r, http.StatusOK, d, nil, nil, "")
}
// HandleApplicationCreate creates an application with all of its settings and
@@ -449,47 +588,34 @@ func (h *Handlers) HandleApplicationCreate(w http.ResponseWriter, r *http.Reques
return
}
if err := r.ParseForm(); err != nil {
h.renderDomainDetail(w, r, http.StatusBadRequest, d,
detailView{FormErr: "Invalid form submission.", FormMode: store.AddressModeWildcard})
h.renderApplicationForm(w, r, http.StatusBadRequest, d, nil, nil, "Invalid form submission.")
return
}
login := strings.TrimSpace(r.PostFormValue("login"))
repopulate := detailView{
FormLogin: login,
FormMode: r.PostFormValue("mode"),
FormAddrs: r.PostFormValue("addresses"),
}
submitted := submittedAppForm(r)
set, err := h.parseApplicationForm(r)
if err != nil {
repopulate.FormErr = err.Error()
h.renderDomainDetail(w, r, http.StatusBadRequest, d, repopulate)
h.renderApplicationForm(w, r, http.StatusBadRequest, d, nil, &submitted, err.Error())
return
}
a, password, err := h.apps.CreateWithSettings(d.ID, login, set, h.recalcApp)
a, password, err := h.apps.CreateWithSettings(d.ID, submitted.Login, set, h.recalcApp)
if err != nil {
repopulate.FormErr = applicationErrorMessage(err)
status := http.StatusBadRequest
if errors.Is(err, store.ErrLoginExists) {
status = http.StatusConflict
}
h.renderDomainDetail(w, r, status, d, repopulate)
h.renderApplicationForm(w, r, status, d, nil, &submitted, applicationErrorMessage(err))
return
}
noStore(w)
h.renderDomainDetail(w, r, http.StatusCreated, d, detailView{
FormMode: store.AddressModeWildcard,
NewCred: &newCred{Login: a.Login, Password: password},
})
h.renderPasswordOnce(w, r, http.StatusCreated, d, a, password, true)
}
// HandleApplicationEdit shows an application's form. Until the domain page is
// split (stage 2) that is the application's Edit panel on the domain page.
// HandleApplicationEdit shows an application's form.
func (h *Handlers) HandleApplicationEdit(w http.ResponseWriter, r *http.Request) {
_, d, ok := h.lookupDomainApplication(w, r)
a, d, ok := h.lookupDomainApplication(w, r)
if !ok {
return
}
h.renderDomainDetail(w, r, http.StatusOK, d, detailView{FormMode: store.AddressModeWildcard})
h.renderApplicationForm(w, r, http.StatusOK, d, &a, nil, "")
}
// HandleApplicationSave saves an application's form: sender, client IPs and
@@ -500,10 +626,8 @@ func (h *Handlers) HandleApplicationSave(w http.ResponseWriter, r *http.Request)
return
}
fail := func(msg string) {
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
FormMode: store.AddressModeWildcard,
RateLimitErr: fmt.Sprintf("%s: %s", a.Login, msg),
})
submitted := submittedAppForm(r)
h.renderApplicationForm(w, r, http.StatusBadRequest, d, &a, &submitted, fmt.Sprintf("%s: %s", a.Login, msg))
}
if err := r.ParseForm(); err != nil {
fail("invalid form submission")
@@ -539,11 +663,7 @@ func (h *Handlers) HandleRegenPassword(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
noStore(w)
h.renderDomainDetail(w, r, http.StatusOK, d, detailView{
FormMode: store.AddressModeWildcard,
NewCred: &newCred{Login: a.Login, Password: password},
})
h.renderPasswordOnce(w, r, http.StatusOK, d, a, password, false)
}
// HandleDeleteApplication removes an application and returns to its domain page
+1 -4
View File
@@ -142,10 +142,7 @@ func (h *Handlers) HandleExportDomain(w http.ResponseWriter, r *http.Request) {
}
password, pwErr := secretFilePassword(r)
if pwErr != "" {
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
FormMode: store.AddressModeWildcard,
ExportErr: pwErr,
})
h.renderDomainSettings(w, r, http.StatusBadRequest, d, settingsView{Err: pwErr})
return
}
exp, err := h.domains.Export(d.ID)
@@ -304,7 +304,7 @@ func TestOutboundRoutesAnswerAnotherTenant404(t *testing.T) {
}
routes := []outRoute{
{"GET", "/outbound/domains/{id}", h.HandleDomainDetail, did},
{"GET", "/outbound/domains/{id}/settings", h.HandleDomainDetail, did},
{"GET", "/outbound/domains/{id}/settings", h.HandleDomainSettings, did},
{"POST", "/outbound/domains/{id}/dns-recheck", h.HandleDomainDNSRecheck, did},
{"POST", "/outbound/domains/{id}/settings/ratelimit", h.HandleDomainRateLimit, did},
{"POST", "/outbound/domains/{id}/settings/ratelimit/recalc", h.HandleDomainRateLimitRecalc, did},
+21 -7
View File
@@ -21,7 +21,7 @@ func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) {
return
}
if err := r.ParseForm(); err != nil {
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Invalid form submission."})
h.renderDomainSettings(w, r, http.StatusBadRequest, d, settingsView{Err: "Invalid form submission."})
return
}
@@ -29,29 +29,43 @@ func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) {
// every other mode gives the domain an address of its own ("" = none).
follow := false
rua := ""
switch strings.TrimSpace(r.PostFormValue("dmarc_rua_mode")) {
mode := strings.TrimSpace(r.PostFormValue("dmarc_rua_mode"))
// A refusal shows the form again with the choice that was made.
refuse := func(status int, msg string) {
h.renderDomainSettings(w, r, status, d, settingsView{Err: msg, RuaMode: mode, RuaCustom: strings.TrimSpace(r.PostFormValue("dmarc_rua_email"))})
}
switch mode {
case "keep":
// The domain follows another user's default and the form left it so.
// Anything else under this value is a stale or forged form.
if p, ok := h.principal(r); !ok || !d.DMARCRuaUserID.Valid || d.DMARCRuaUserID.Int64 == p.ID {
refuse(http.StatusBadRequest, "Choose how aggregate reports are addressed for this domain.")
return
}
http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?dmarc=1", d.ID), http.StatusSeeOther)
return
case "inherit":
follow = true
case "none":
case "custom":
email := strings.TrimSpace(r.PostFormValue("dmarc_rua_email"))
if err := validate.Email(email); err != nil {
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: err.Error()})
refuse(http.StatusBadRequest, err.Error())
return
}
if email == "" {
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Enter a custom report address or choose another mode."})
refuse(http.StatusBadRequest, "Enter a custom report address or choose another mode.")
return
}
rua = email
case "hosted":
if h.dmarc == nil || !h.dmarc.Enabled() {
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "SelfPost-hosted reports are not enabled on this server."})
refuse(http.StatusBadRequest, "SelfPost-hosted reports are not enabled on this server.")
return
}
rua = dmarc.HostedReportAddress(h.cfg.Hostname, d.Name)
default:
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Choose how aggregate reports are addressed for this domain."})
h.renderDomainSettings(w, r, http.StatusBadRequest, d, settingsView{Err: "Choose how aggregate reports are addressed for this domain."})
return
}
@@ -63,7 +77,7 @@ func (h *Handlers) HandleDomainDMARC(w http.ResponseWriter, r *http.Request) {
}
if err != nil {
logf("panel: domain %d: save dmarc rua: %v", d.ID, err)
h.renderDomainDetail(w, r, http.StatusInternalServerError, d, detailView{FormErr: "Could not save DMARC settings. Please check the logs and try again."})
refuse(http.StatusInternalServerError, "Could not save DMARC settings. Please check the logs and try again.")
return
}
if h.dmarc != nil && h.dmarc.Enabled() {
+43 -34
View File
@@ -6,24 +6,17 @@ import (
"net/http"
"strconv"
"sync"
"time"
"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"
"github.com/mixeme/selfpost/internal/web/view"
)
// domainRow is one line of the domain list: the stored domain plus the rolled-up
// verdict of its published DNS records, so the operator sees which domains still
// need a record published without opening each one.
type domainRow struct {
store.Domain
DNS health.Status
}
// HandleDashboard is the authenticated landing page: the list of sending
// domains with their DKIM/selector and application counts, plus the add-domain
// form (product.md).
// 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, "", "")
}
@@ -36,40 +29,52 @@ func (h *Handlers) renderDashboard(w http.ResponseWriter, r *http.Request, statu
}
domains, err := h.assignedDomains(p)
if err != nil {
logf("panel: dashboard: list domains: %v", err)
logf("panel: domains: list domains: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
data := h.pageBase(r)
data["Title"] = "SelfPost — domains"
data["Active"] = "domains"
data["Domains"] = h.domainRows(domains)
data["Error"] = formErr
data["FormName"] = formName
data["Flash"] = dashboardFlash(r)
h.view.Render(w, status, "dashboard", data)
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)
}
func (h *Handlers) domainRows(domains []store.Domain) []domainRow {
rows := make([]domainRow, len(domains))
// 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] = domainRow{Domain: d, DNS: health.StatusUnknown}
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: dashboard: domain %d: dkim record: %v", d.ID, err)
logf("panel: domains: domain %d: dkim record: %v", d.ID, err)
return
}
reportEmail, err := h.domainReportAddress(d)
if err != nil {
logf("panel: dashboard: domain %d: dmarc report address: %v", d.ID, err)
logf("panel: domains: domain %d: dmarc report address: %v", d.ID, err)
return
}
dns, _ := h.domainDNS(d, record, reportEmail, false)
rows[i].DNS = dns.Overall
rows[i].DNS = dnsTags(dns.DKIM.Status, dns.SPF.Status, dns.DMARC.Status)
}()
}
wg.Wait()
@@ -142,13 +147,17 @@ func (h *Handlers) HandleDeleteConfirm(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
h.view.Render(w, http.StatusOK, "domain_delete", map[string]any{
"Title": "SelfPost — delete " + d.Name,
"User": auth.CurrentUser(r),
"Active": "domains",
"Domain": d,
"IsGlobal": true,
})
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.
@@ -0,0 +1,396 @@
package handlers
import (
"net/http"
"net/url"
"strings"
"testing"
"github.com/mixeme/selfpost/internal/app"
"github.com/mixeme/selfpost/internal/store"
)
// The pages of the Outbound group, rendered from the real stores and the
// handlers, with the refusals each form can come back with. Their markup is
// held to the mockups by the guards in internal/web/view; what is checked here
// is that the handlers fill the typed pages from what is stored and that every
// state a form can end in lands on the page that owns it.
func has(t *testing.T, where, body string, wants ...string) {
t.Helper()
for _, w := range wants {
if !strings.Contains(body, w) {
t.Errorf("%s is missing %q:\n%s", where, w, body)
}
}
}
func lacks(t *testing.T, where, body string, gone ...string) {
t.Helper()
for _, g := range gone {
if strings.Contains(body, g) {
t.Errorf("%s carries %q", where, g)
}
}
}
// pageOf reads a page of domain 1 as the global administrator, binding the
// path's {id} the way the router would.
func pageOf(t *testing.T, h http.HandlerFunc, target string) string {
t.Helper()
rec := send(h, &globalPrincipal, "GET", target, map[string]string{"id": "1"}, nil)
if rec.Code != http.StatusOK {
t.Fatalf("GET %s = %d, want 200:\n%s", target, rec.Code, rec.Body.String())
}
return rec.Body.String()
}
func TestDomainsListShowsTheAddFormToTheGlobalRoleOnly(t *testing.T) {
s := newAppStand(t)
body := getBody(t, s.h.HandleDashboard, "/outbound/domains")
has(t, "list", body, `<h1 class="title is-3">Outbound domains</h1>`, `<h2>Add a domain</h2>`, `action="/outbound/domains"`,
`<strong>example.org</strong>`, `href="/outbound/domains/1/delete"`, `1 domain · DNS cached`, `>DKIM</span>`, `>SPF</span>`, `>DMARC</span>`, `mail</td>`)
ops := domainAdmin(t, s.h.store, "ops", s.d.ID)
body = getBodyAs(t, s.h.HandleDashboard, "/outbound/domains", ops)
has(t, "list for a domain administrator", body, `<strong>example.org</strong>`, `href="/outbound/domains/1"`)
lacks(t, "list for a domain administrator", body, `Add a domain`, `/delete`)
// What the viewer does not reach is not listed.
other, err := s.h.store.AddDomain("other.example.net", "mail")
if err != nil {
t.Fatal(err)
}
lacks(t, "list for a domain administrator", getBodyAs(t, s.h.HandleDashboard, "/outbound/domains", ops), other.Name)
has(t, "list", getBody(t, s.h.HandleDashboard, "/outbound/domains"), other.Name)
}
// A refused name comes back on the list, with what was typed in the field.
func TestAddDomainRefusalKeepsWhatWasTyped(t *testing.T) {
s := newAppStand(t)
rec := postFormAs(s.h.HandleAddDomain, globalPrincipal, "/outbound/domains", nil, url.Values{"name": {`Not A Domain "<b>`}})
if rec.Code != http.StatusBadRequest {
t.Fatalf("a bad name = %d, want 400:\n%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
has(t, "refused add", body, `<div class="notification is-danger is-light">`, `<h2>Add a domain</h2>`, `<strong>example.org</strong>`)
lacks(t, "refused add", body, `<b>`)
if domains, _ := s.h.store.ListDomains(); len(domains) != 1 {
t.Errorf("a refused name left %d domains", len(domains))
}
}
func TestDomainPageShowsRecordsApplicationsAndConnection(t *testing.T) {
s := newAppStand(t)
s.h.cfg.Hostname, s.h.cfg.SubmissionEnabled = "mail.example.org", true
a, _, err := s.h.apps.CreateWithSettings(s.d.ID, "prod-server", app.Settings{
Mode: store.AddressModeList, Addresses: []string{"alerts@example.org"},
AuthIPRestrict: true, AuthAllowedIPs: []string{"203.0.113.10", "203.0.113.11"},
Limit: app.Limit{Mode: store.RateLimitModeManual, MaxMessages: 200, WindowSeconds: 3600},
}, nil)
if err != nil {
t.Fatal(err)
}
body := pageOf(t, s.h.HandleDomainDetail, "/outbound/domains/1")
has(t, "domain", body,
`<h1 class="title is-3">example.org</h1>`, `class="sp-postmark`, `<h3>DKIM</h3>`, `<h3>SPF</h3>`, `<h3>DMARC</h3>`,
`mail._domainkey.example.org`, `v=DKIM1`, `_dmarc.example.org`, `v=DMARC1; p=none`, `v=spf1 `,
`<td class="sp-mono"><strong>prod-server</strong></td>`, `alerts@<wbr>example.org`, `200 / h`, `2 IPs`,
`href="/outbound/domains/1/applications/`+idStr(a.ID)+`">Edit</a>`,
`mail.example.org`, `STARTTLS (submission)`, `action="/outbound/domains/1/dns-recheck"`,
`href="/outbound/log?domain=example.org"`, `href="/outbound/domains/1/settings#reports"`, `href="/outbound/domains/1/delete"`)
// The flash the redirects carry.
rec := send(s.h.HandleDomainDetail, &globalPrincipal, "GET", "/outbound/domains/1?appsaved=1", s.paths, nil)
has(t, "domain after a save", rec.Body.String(), `<div class="notification is-success is-light">`, `Application saved.`)
// A domain administrator does not get the global role's delete entry.
ops := domainAdmin(t, s.h.store, "ops", s.d.ID)
rec = send(s.h.HandleDomainDetail, &ops, "GET", "/outbound/domains/1", s.paths, nil)
if rec.Code != http.StatusOK {
t.Fatalf("domain page for its administrator = %d", rec.Code)
}
lacks(t, "domain for a domain administrator", rec.Body.String(), `href="/outbound/domains/1/delete"`)
}
func TestDomainPageWithoutApplicationsSaysSo(t *testing.T) {
s := newAppStand(t)
body := pageOf(t, s.h.HandleDomainDetail, "/outbound/domains/1")
has(t, "domain", body, `No applications yet.`)
lacks(t, "domain", body, `<th>Login</th>`)
}
// The report address form offers what the viewer can choose, and says whose
// default the domain follows.
func TestDomainSettingsReportAddressChoices(t *testing.T) {
s := newAppStand(t)
body := pageOf(t, s.h.HandleDomainSettings, "/outbound/domains/1/settings")
has(t, "settings", body, `<h1 class="title is-3">Domain settings</h1>`,
`action="/outbound/domains/1/settings/reports"`, `<option value="inherit">My default — no report address yet</option>`,
`<option value="none" selected>`, `<option value="custom">`,
`action="/outbound/domains/1/settings/ratelimit"`, `action="/outbound/domains/1/settings/export"`,
`name="encrypt" value="1" checked`, `minlength="12"`, `<h2>Delete domain</h2>`, `Removes the DKIM key.`)
lacks(t, "settings", body, `value="hosted"`, `name="clear"`, `ratelimit/recalc`)
// Following the signed-in user's default.
if err := s.h.store.SetDomainDMARCUser(s.d.ID, globalPrincipal.ID); err != nil {
t.Fatal(err)
}
if err := s.h.store.SetDMARCDefault(globalPrincipal.ID, store.DMARCDefaultCustom, "dmarc@hub.example"); err != nil {
t.Fatal(err)
}
body = pageOf(t, s.h.HandleDomainSettings, "/outbound/domains/1/settings")
has(t, "settings", body, `<option value="inherit" selected>admin&#39;s default — dmarc@hub.example</option>`)
// "keep" names nobody when the domain follows the person saving: refused.
if rec := postFormAs(s.h.HandleDomainDMARC, globalPrincipal, "/outbound/domains/1/settings/reports", map[string]string{"id": "1"},
url.Values{"dmarc_rua_mode": {"keep"}}); rec.Code != http.StatusBadRequest {
t.Errorf("keep on a domain that follows the saver = %d, want 400", rec.Code)
}
// Following someone else's: theirs is selected and is what an untouched form
// saves; one's own is offered as the change.
other := domainAdmin(t, s.h.store, "ops", s.d.ID)
if err := s.h.store.SetDMARCDefault(other.ID, store.DMARCDefaultCustom, "ops@hub.example"); err != nil {
t.Fatal(err)
}
if err := s.h.store.SetDomainDMARCUser(s.d.ID, other.ID); err != nil {
t.Fatal(err)
}
body = pageOf(t, s.h.HandleDomainSettings, "/outbound/domains/1/settings")
has(t, "settings", body, `<option value="keep" selected>ops&#39;s default — ops@hub.example</option>`,
`<option value="inherit">My default instead — dmarc@hub.example (you are admin)</option>`)
if rec := postFormAs(s.h.HandleDomainDMARC, globalPrincipal, "/outbound/domains/1/settings/reports", map[string]string{"id": "1"},
url.Values{"dmarc_rua_mode": {"keep"}}); rec.Code != http.StatusSeeOther {
t.Fatalf("saving the form untouched = %d, want 303:\n%s", rec.Code, rec.Body.String())
}
if got, err := s.h.store.GetDomain(s.d.ID); err != nil || !got.DMARCRuaUserID.Valid || got.DMARCRuaUserID.Int64 != other.ID {
t.Fatalf("an untouched form took the domain over: follows %v, %v; want user %d", got.DMARCRuaUserID, err, other.ID)
}
// An address of its own shows in the field.
if err := s.h.store.SetDomainDMARCAddress(s.d.ID, "reports@hub.example"); err != nil {
t.Fatal(err)
}
body = pageOf(t, s.h.HandleDomainSettings, "/outbound/domains/1/settings")
has(t, "settings", body, `<option value="custom" selected>`, `value="reports@hub.example"`)
}
func TestDomainSettingsShowsTheRateLimit(t *testing.T) {
s := newAppStand(t)
if err := s.h.domains.SaveRateLimit(s.d.ID, store.RateLimit{Mode: store.RateLimitModeManual, MaxMessages: 250, WindowSeconds: 3600}); err != nil {
t.Fatal(err)
}
body := pageOf(t, s.h.HandleDomainSettings, "/outbound/domains/1/settings")
has(t, "settings", body, `<option value="manual" selected>`, `value="250"`, `<span class="tag is-success is-light">active</span>`,
`name="clear" value="1"`, `max="600"`, `600 messages / 3600 s per client IP`, `Multiplier (1.5–5, default 2.5)`)
lacks(t, "settings", body, `ratelimit/recalc`)
}
// Each form of the settings page answers its refusal on the settings page, with
// the status it always had and the message in the flash.
func TestDomainSettingsRefusalsStayOnTheSettingsPage(t *testing.T) {
s := newAppStand(t)
post := func(h http.HandlerFunc, path string, form url.Values) (int, string) {
rec := postFormAs(h, globalPrincipal, "/outbound/domains/1/settings/"+path, s.paths, form)
return rec.Code, rec.Body.String()
}
code, body := post(s.h.HandleDomainRateLimit, "ratelimit", url.Values{"mode": {"manual"}, "max_messages": {"601"}})
if code != http.StatusBadRequest {
t.Fatalf("a limit above level 1 = %d", code)
}
has(t, "rate limit refusal", body, `<div class="notification is-danger is-light">`, `level-1 backstop (600)`, `<h1 class="title is-3">Domain settings</h1>`)
code, body = post(s.h.HandleDomainRateLimitRecalc, "ratelimit/recalc", url.Values{})
if code != http.StatusBadRequest {
t.Fatalf("recalculating a limit that is not automatic = %d", code)
}
has(t, "recalculation refusal", body, `rate limit not in auto mode`, `<h1 class="title is-3">Domain settings</h1>`)
code, body = post(s.h.HandleDomainDMARC, "reports", url.Values{"dmarc_rua_mode": {"custom"}, "dmarc_rua_email": {"not an address<b>"}})
if code != http.StatusBadRequest {
t.Fatalf("a bad report address = %d", code)
}
has(t, "report address refusal", body, `<div class="notification is-danger is-light">`, `<option value="custom" selected>`, `not an address&lt;b&gt;`)
lacks(t, "report address refusal", body, `<b>`)
code, body = post(s.h.HandleDomainDMARC, "reports", url.Values{"dmarc_rua_mode": {"custom"}})
has(t, "report address refusal", body, `Enter a custom report address or choose another mode.`)
code, body = post(s.h.HandleDomainDMARC, "reports", url.Values{"dmarc_rua_mode": {"hosted"}})
if code != http.StatusBadRequest {
t.Fatalf("hosted reports with ingest off = %d", code)
}
has(t, "report address refusal", body, `SelfPost-hosted reports are not enabled on this server.`)
code, body = post(s.h.HandleDomainDMARC, "reports", url.Values{"dmarc_rua_mode": {"bogus"}})
if code != http.StatusBadRequest {
t.Fatalf("an unknown mode = %d", code)
}
has(t, "report address refusal", body, `Choose how aggregate reports are addressed for this domain.`)
code, body = post(s.h.HandleExportDomain, "export", url.Values{"encrypt": {"1"}, "password": {"short"}, "password_confirm": {"short"}})
if code != http.StatusBadRequest {
t.Fatalf("a short export password = %d", code)
}
has(t, "export refusal", body, `The encryption password must be at least 12 characters.`, `<h1 class="title is-3">Domain settings</h1>`)
code, body = post(s.h.HandleExportDomain, "export", url.Values{"encrypt": {"1"}, "password": {"long-enough-password"}, "password_confirm": {"another-one-entirely"}})
has(t, "export refusal", body, `The two passwords do not match.`)
}
func TestApplicationNewFormIsBlank(t *testing.T) {
s := newAppStand(t)
body := pageOf(t, s.h.HandleApplicationNew, "/outbound/domains/1/applications/new")
has(t, "new application", body, `<h1 class="title is-3">New application</h1>`,
`action="/outbound/domains/1/applications/new"`, `Create application`, `name="login" value=""`,
`<option value="wildcard" selected>`, `<option value="domain" selected>`, `value="3600"`, `placeholder="alerts@example.org"`)
lacks(t, "new application", body, ` readonly`, `/password`, `/delete`, `Recalculate`)
}
func TestApplicationEditFormShowsWhatIsStored(t *testing.T) {
s := newAppStand(t)
a, _, err := s.h.apps.CreateWithSettings(s.d.ID, "prod-server", app.Settings{
Mode: store.AddressModeList, Addresses: []string{"alerts@example.org", "noc@example.org"},
AuthIPRestrict: true, AuthAllowedIPs: []string{"203.0.113.10"},
Limit: app.Limit{Mode: store.RateLimitModeManual, MaxMessages: 200, WindowSeconds: 1800},
}, nil)
if err != nil {
t.Fatal(err)
}
if err := s.h.domains.SaveRateLimit(s.d.ID, store.RateLimit{Mode: store.RateLimitModeManual, MaxMessages: 288, WindowSeconds: 3600}); err != nil {
t.Fatal(err)
}
own := "/outbound/domains/1/applications/" + idStr(a.ID)
rec := send(s.h.HandleApplicationEdit, &globalPrincipal, "GET", own, map[string]string{"id": "1", "aid": idStr(a.ID)}, nil)
body := rec.Body.String()
if rec.Code != http.StatusOK {
t.Fatalf("edit form = %d:\n%s", rec.Code, body)
}
has(t, "edit form", body, `<h1 class="title is-3 sp-mono">prod-server</h1>`, `action="`+own+`"`, `name="login" value="prod-server" readonly`,
`<option value="list" selected>`, "alerts@example.org\nnoc@example.org</textarea>", "203.0.113.10</textarea>", ` checked> Restrict to listed IPs`,
`<option value="manual" selected>`, `value="200"`, `value="1800"`, `Overrides the domain limit (288 / h)`,
`action="`+own+`/password" data-confirm=`, `action="`+own+`/delete" data-confirm=`, `Save application`)
lacks(t, "edit form", body, `Recalculate`)
// An automatic limit offers its recalculation.
if err := s.h.apps.SaveRateLimit(a.ID, store.RateLimit{Mode: store.RateLimitModeAuto, AutoMultiplier: 2, WindowSeconds: 3600, MaxMessages: 40}); err != nil {
t.Fatal(err)
}
rec = send(s.h.HandleApplicationEdit, &globalPrincipal, "GET", own, map[string]string{"id": "1", "aid": idStr(a.ID)}, nil)
has(t, "edit form", rec.Body.String(), `formaction="`+own+`/ratelimit/recalc"`, `<option value="auto" selected>`, `Computed limit: <strong>40</strong>`)
}
// A refusal of the application form comes back on the form with what was typed,
// the status it always had, and creates or changes nothing.
func TestApplicationFormRefusalsComeBackOnTheForm(t *testing.T) {
s := newAppStand(t)
typed := url.Values{
"login": {"has space"}, "mode": {"list"}, "addresses": {"a@example.org"}, "auth_ip_restrict": {"1"},
"auth_allowed_ips": {"203.0.113.10"}, "rl_mode": {"manual"}, "max_messages": {"601"}, "window_seconds": {"900"},
}
rec := postFormAs(s.h.HandleApplicationCreate, globalPrincipal, "/outbound/domains/1/applications/new", s.paths, typed)
if rec.Code != http.StatusBadRequest {
t.Fatalf("a limit above level 1 = %d:\n%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
has(t, "refused create", body, `<div class="notification is-danger is-light">`, `level-1 backstop (600)`,
`name="login" value="has space"`, `<option value="list" selected>`, "a@example.org</textarea>", "203.0.113.10</textarea>",
` checked> Restrict to listed IPs`, `<option value="manual" selected>`, `value="601"`, `value="900"`, `Create application`)
lacks(t, "refused create", body, `Shown once`)
if rec.Header().Get("Cache-Control") == "no-store" {
t.Error("a refusal is marked as a page that shows a secret")
}
typed.Set("max_messages", "100")
typed.Set("login", "taken")
if _, _, err := s.h.apps.CreateWithSettings(s.d.ID, "taken", app.Settings{Mode: store.AddressModeWildcard}, nil); err != nil {
t.Fatal(err)
}
rec = postFormAs(s.h.HandleApplicationCreate, globalPrincipal, "/outbound/domains/1/applications/new", s.paths, typed)
if rec.Code != http.StatusConflict {
t.Fatalf("a login that exists = %d, want 409:\n%s", rec.Code, rec.Body.String())
}
has(t, "refused create", rec.Body.String(), `That login is already in use. Choose another.`, `name="login" value="taken"`)
}
func TestApplicationSaveRefusalKeepsTheLoginFixed(t *testing.T) {
s := newAppStand(t)
a, _, err := s.h.apps.CreateWithSettings(s.d.ID, "prod-server", app.Settings{Mode: store.AddressModeWildcard}, nil)
if err != nil {
t.Fatal(err)
}
paths := map[string]string{"id": "1", "aid": idStr(a.ID)}
own := "/outbound/domains/1/applications/" + idStr(a.ID)
rec := postFormAs(s.h.HandleApplicationSave, globalPrincipal, own, paths, url.Values{
"login": {"renamed"}, "mode": {"wildcard"}, "rl_mode": {"manual"}, "max_messages": {"500"},
})
if rec.Code != http.StatusBadRequest {
t.Fatalf("a changed login = %d", rec.Code)
}
body := rec.Body.String()
has(t, "refused save", body, `prod-server: the login of an application cannot be changed`, `name="login" value="prod-server" readonly`,
`<option value="manual" selected>`, `value="500"`)
lacks(t, "refused save", body, `value="renamed"`)
rec = postFormAs(s.h.HandleApplicationSave, globalPrincipal, own, paths, url.Values{"mode": {"wildcard"}, "rl_mode": {"manual"}, "max_messages": {"601"}})
has(t, "refused save", rec.Body.String(), `prod-server: message limit cannot exceed the level-1 backstop (600)`)
// The recalculation of a limit that is not automatic is refused on the form too.
rec = postFormAs(s.h.HandleAppRateLimitRecalc, globalPrincipal, own+"/ratelimit/recalc", paths, url.Values{})
if rec.Code != http.StatusBadRequest {
t.Fatalf("recalculating a limit that is not automatic = %d", rec.Code)
}
has(t, "refused recalculation", rec.Body.String(), `prod-server: rate limit not in auto mode`, `Save application`)
}
// The password page is the response of its POST: shown once, in no cache, and
// the same for a new application and for a new password.
func TestPasswordIsShownOnceAndNeverCached(t *testing.T) {
s := newAppStand(t)
s.h.cfg.Hostname, s.h.cfg.SubmissionEnabled = "mail.example.org", true
rec := postFormAs(s.h.HandleApplicationCreate, globalPrincipal, "/outbound/domains/1/applications/new", s.paths, url.Values{
"login": {"prod-server"}, "mode": {"list"}, "addresses": {"alerts@example.org"},
})
if rec.Code != http.StatusCreated || rec.Header().Get("Cache-Control") != "no-store" || rec.Header().Get("Pragma") != "no-cache" {
t.Fatalf("create = %d, Cache-Control %q", rec.Code, rec.Header().Get("Cache-Control"))
}
password := s.sasl["prod-server"]
has(t, "created", rec.Body.String(), `<h2>Shown once</h2>`, `value="`+password+`"`, `value="prod-server"`,
`If this one is lost, generate another from the application&#39;s page.`, `alerts@example.org`, `465 SSL/TLS · 587 STARTTLS`,
`href="/outbound/domains/1">Done — back to example.org</a>`)
lacks(t, "created", rec.Body.String(), `stopped working`)
a, _, _ := s.app(t, "prod-server")
paths := map[string]string{"id": "1", "aid": idStr(a.ID)}
rec = postFormAs(s.h.HandleRegenPassword, globalPrincipal, "/outbound/domains/1/applications/1/password", paths, url.Values{})
if rec.Code != http.StatusOK || rec.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("new password = %d, Cache-Control %q", rec.Code, rec.Header().Get("Cache-Control"))
}
renewed := s.sasl["prod-server"]
if renewed == password {
t.Fatal("the password did not change")
}
has(t, "new password", rec.Body.String(), `value="`+renewed+`"`, `The previous password stopped working when this one was generated.`)
lacks(t, "new password", rec.Body.String(), `value="`+password+`"`)
}
func TestDeleteConfirmNamesTheApplicationsThatGoWithTheDomain(t *testing.T) {
s := newAppStand(t)
rec := send(s.h.HandleDeleteConfirm, &globalPrincipal, "GET", "/outbound/domains/1/delete", s.paths, nil)
body := rec.Body.String()
if rec.Code != http.StatusOK {
t.Fatalf("delete confirmation = %d:\n%s", rec.Code, body)
}
has(t, "delete", body, `<h1 class="title is-3">Delete example.org</h1>`, `permanently delete its DKIM signing key;`,
`<form method="post" action="/outbound/domains/1/delete">`, `Delete example.org</button>`, `href="/outbound/domains/1/settings#export"`)
lacks(t, "delete", body, `credentials`)
for _, login := range []string{"prod-server", "alerts"} {
if _, _, err := s.h.apps.CreateWithSettings(s.d.ID, login, app.Settings{Mode: store.AddressModeWildcard}, nil); err != nil {
t.Fatal(err)
}
}
rec = send(s.h.HandleDeleteConfirm, &globalPrincipal, "GET", "/outbound/domains/1/delete", s.paths, nil)
has(t, "delete", rec.Body.String(), `<strong>both applications</strong>`, `<span class="sp-mono">alerts</span>, <span class="sp-mono">prod-server</span>`)
}
+3 -12
View File
@@ -151,10 +151,7 @@ func (h *Handlers) HandleDomainRateLimit(w http.ResponseWriter, r *http.Request)
}
in, err := parseRateLimitForm(r, h.l1Messages())
if err != nil {
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
FormMode: store.AddressModeWildcard,
RateLimitErr: err.Error(),
})
h.renderDomainSettings(w, r, http.StatusBadRequest, d, settingsView{Err: err.Error()})
return
}
if err := h.applyRateLimit(in, store.RateLimitScopeDomain, d.ID, h.domains); err != nil {
@@ -172,10 +169,7 @@ func (h *Handlers) HandleDomainRateLimitRecalc(w http.ResponseWriter, r *http.Re
}
if err := h.recalcRateLimit(store.RateLimitScopeDomain, d.ID); err != nil {
logf("panel: domain %d: recalc rate limit: %v", d.ID, err)
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
FormMode: store.AddressModeWildcard,
RateLimitErr: err.Error(),
})
h.renderDomainSettings(w, r, http.StatusBadRequest, d, settingsView{Err: err.Error()})
return
}
http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?recalculated=1", d.ID), http.StatusSeeOther)
@@ -187,10 +181,7 @@ func (h *Handlers) HandleAppRateLimitRecalc(w http.ResponseWriter, r *http.Reque
return
}
if err := h.recalcRateLimit(store.RateLimitScopeApp, a.ID); err != nil {
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
FormMode: store.AddressModeWildcard,
RateLimitErr: fmt.Sprintf("%s: %s", a.Login, err.Error()),
})
h.renderApplicationForm(w, r, http.StatusBadRequest, d, &a, nil, fmt.Sprintf("%s: %s", a.Login, err.Error()))
return
}
http.Redirect(w, r, fmt.Sprintf("/outbound/domains/%d?recalculated=1", a.DomainID), http.StatusSeeOther)