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)
+5 -1
View File
@@ -184,12 +184,16 @@ type Crumb struct {
}
// Action is a button in a Head or a Foot: a link to Href, or a POST form
// submitting to Post. Icon is a Tabler class ("ti-refresh").
// submitting to Post. Icon is a Tabler class ("ti-refresh"). Confirm, set on a
// POST that deletes something or ends a working password, is the question
// panel.js asks before the form is sent (data-confirm); it adds an attribute and
// no markup.
type Action struct {
Label string
Icon string
Href string
Post string
Confirm string
Primary bool
Danger bool
}
+95
View File
@@ -27,6 +27,13 @@ var pageFixtures = map[string]func() any{
"health": func() any { return healthFixture(false) },
"account": func() any { return accountFixture() },
"settings": func() any { return settingsFixture() },
"out-domains": func() any { return outDomainsFixture() },
"out-domain": func() any { return outDomainFixture() },
"out-domain-settings": func() any { return outDomainSettingsFixture() },
"out-app": func() any { return outAppFixture() },
"out-app-created": func() any { return outAppCreatedFixture() },
"out-domain-delete": func() any { return outDomainDeleteFixture() },
}
// admin is the signed-in global administrator every fixture is rendered for.
@@ -111,3 +118,91 @@ func accountFixture() *Account {
func settingsFixture() *Settings {
return NewSettings(admin(), "30", FormatRate(600, 3600))
}
// outDomainsFixture is the mockup's Outbound domains: three domains, the first
// with a wrong SPF record and the last with a weak DMARC one.
func outDomainsFixture() *OutDomains {
dns := func(dkim, spf, dmarc string) []Tag {
return []Tag{{Status: dkim, Label: "DKIM"}, {Status: spf, Label: "SPF"}, {Status: dmarc, Label: "DMARC"}}
}
row := func(id, name string, tags []Tag, selector string, apps int, total, peak int64) OutDomainRow {
return OutDomainRow{Name: name, Href: "/outbound/domains/" + id, DeleteHref: "/outbound/domains/" + id + "/delete",
DNS: tags, Selector: selector, Apps: apps, Activity: FormatActivity(total, peak)}
}
return NewOutDomains(admin(), true, 30).WithRows([]OutDomainRow{
row("1", "example.org", dns("ok", "error", "ok"), "sp2026", 2, 1284, 96),
row("2", "shop.example.org", dns("ok", "ok", "ok"), "sp2026", 1, 18920, 410),
row("3", "notify.acme.io", dns("ok", "ok", "warn"), "sp2025", 3, 402, 22),
})
}
// outDomainFixture is the mockup's domain page: the DKIM and DMARC records
// published, the SPF one pointing at the wrong server, two applications.
func outDomainFixture() *OutDomain {
p := NewOutDomain(admin(), 1, "example.org", true)
p.WithStats(1284, 96, "1.8", 30)
p.WithRecords([]Record{
DKIMRecord("sp2026", "sp2026._domainkey.example.org",
"v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx3kqV0mJd8e2f1Yb7Qw5uT9sLr4nHc6pZaXo1vKe0yWm2gBt8RjN5dCq3hUf7lPs9aEi4oMz6xTb1kGv0nYw…IDAQAB",
DNSCheck{Status: "ok"}),
SPFRecord("example.org", "v=spf1 ip4:203.0.113.25 ~all", DNSCheck{Status: "error",
Detail: "The published record does not authorize 203.0.113.25. Add ip4:203.0.113.25 before ~all.",
Found: []string{"v=spf1 include:_spf.google.com ~all"}}),
DMARCRecord(DMARCRecordInput{Host: "_dmarc.example.org", Value: "v=DMARC1; p=none; rua=mailto:dmarc@mail.example.org",
Check: DNSCheck{Status: "ok"}, Source: "admin's default", SettingsHref: "/outbound/domains/1/settings",
ReportsHref: "/outbound/dmarc/domains/1"}),
}, "3 min ago")
p.WithApplications([]OutAppRow{
{Login: "prod-server", Senders: []string{"*@example.org"}, Activity: FormatActivity(1102, 96),
Limits: []Tag{{Status: "ok", Label: "200 / h"}}, Edit: "/outbound/domains/1/applications/1"},
{Login: "alerts", Senders: []string{"alerts@example.org", "noc@example.org"}, Activity: FormatActivity(182, 14),
Limits: []Tag{{Label: "domain"}, {Status: "ok", Label: "2 IPs"}}, Edit: "/outbound/domains/1/applications/2"},
})
p.WithConnection("mail.example.org", true)
p.WithSeeAlso("/outbound/log?domain=example.org", "/outbound/dmarc/domains/1")
p.WithRateLimit(true, false)
return p
}
// outDomainSettingsFixture is the mockup's Domain settings: reports go to the
// default of the signed-in administrator, the rate limit is automatic.
func outDomainSettingsFixture() *OutDomainSettings {
p := NewOutDomainSettings(admin(), 1, "example.org", 2, true)
p.WithReportAddress([]Option{
{Value: "inherit", Label: "admin's default — mix@example.org"},
{Value: "hosted", Label: "SelfPost hosted (example.org@dmarc.mail.example.org)"},
{Value: "none", Label: "No aggregate reports"},
{Value: "custom", Label: "Custom address"},
}, "inherit", "", Rich("A default belongs to a user and is set under their ", 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."))
p.WithExport(12)
return p.WithRateLimit(DomainRateLimit{
Active: true, Auto: true, MaxMessages: "288", Window: "3600", Multiplier: "2.5",
Computed: "288", Peak: 96, Updated: "2026-10-09 03:00 UTC",
L1Messages: 600, L1Window: 3600, MinMultiplier: "1.5", MaxMultiplier: "5", DefaultMultiplier: "2.5",
})
}
// outAppFixture is the mockup's application form, filled with prod-server: any
// address of the domain, no client IPs, a manual limit of 200 an hour.
func outAppFixture() *OutApp {
p := NewOutApp(admin(), 1, "example.org", "prod-server")
p.WithApplication(1, 1, 1102, 96, "1.5", 30)
return p.WithForm(OutAppForm{
Login: "prod-server", Mode: AddressWildcard, LimitMode: LimitManual, MaxMessages: "200", Window: "3600", Multiplier: "2.5",
}, OutAppState{Limit: true}, AppRateLimit{L1Messages: 600, L1Window: 3600, MinMultiplier: "1.5", MaxMultiplier: "5",
DefaultMultiplier: "2.5", DomainLimit: "288 / h", Peak: 96})
}
// outAppCreatedFixture is the mockup's password page: a new password for
// prod-server, which may send as the whole domain.
func outAppCreatedFixture() *OutAppCreated {
return NewOutAppCreated(admin(), 1, "example.org", "prod-server", "kT7v-Qm2x-Lp9d-Ue4s-Hn6b-Rz3w", false,
"mail.example.org", true, []string{"*@example.org"})
}
// outDomainDeleteFixture is the mockup's confirmation: the domain has the two
// applications that go with it.
func outDomainDeleteFixture() *OutDomainDelete {
return NewOutDomainDelete(admin(), 1, "example.org", []string{"prod-server", "alerts"})
}
-3
View File
@@ -11,14 +11,11 @@
# @routes the pre-2.0 route table is still in place (stage 1)
# <name> a page of the view engine that is still the old template
backup
dashboard
deliveries
delivery
dmarc
dmarc_domain
dmarc_report
domain_delete
domain_detail
help
inbound
inbound_delete
+724
View File
@@ -0,0 +1,724 @@
package view
// The data of the Outbound domain pages: the list (templates/out_domains.html,
// mockup out-domains.html), one domain (out_domain.html), its settings
// (out_domain_settings.html) and the confirmation before it is deleted
// (out_domain_delete.html). The application form and the page that shows a
// password once are in outbound_app.go.
//
// A page is a typed struct that embeds Meta, built by a constructor and filled
// by With… methods; the handler reads the stores and the DNS checker, the view
// decides what is said and how it is worded.
import (
"fmt"
"strconv"
"strings"
"time"
)
const outboundDomains = "/outbound/domains"
// outboundCrumbs is the trail above a page of one domain.
func outboundCrumbs(id int64, name string) []Crumb {
return []Crumb{{Href: outboundDomains, Label: "Outbound domains"}, {Href: DomainHref(id), Label: name}}
}
// DomainHref is the address of an outbound domain's page.
func DomainHref(id int64) string { return outboundDomains + "/" + strconv.FormatInt(id, 10) }
// plural writes n and the noun in the right number: "1 message", "2 messages".
func plural(n int64, one, many string) string {
if n == 1 {
return FormatCount(n) + " " + one
}
return FormatCount(n) + " " + many
}
// StatsLead is the sentence under the title of a domain or an application: how
// much mail it sent in the window, its busiest hour and its average. avg is the
// average per hour as the store formats it.
func StatsLead(total, peak int64, avg string, days int) Text {
return Plain(plural(total, "message", "messages") + " in " + plural(int64(days), "day", "days") +
" · peak " + FormatCount(peak) + " msg/h · average " + avg + " msg/h")
}
// FormatActivity is the Domains table's last column: the mail of the window and
// its busiest hour ("1 284 msg · peak 96/h").
func FormatActivity(total, peak int64) string {
return FormatMessages(total) + " · peak " + FormatCount(peak) + "/h"
}
// FormatAge says how long ago something was, as a box's end slot puts it:
// "just now", "3 min ago", "2 h ago", then the date. A zero time is "".
func FormatAge(t, now time.Time) string {
if t.IsZero() {
return ""
}
switch d := now.Sub(t); {
case d < time.Minute:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%d min ago", int(d/time.Minute))
case d < 48*time.Hour:
return fmt.Sprintf("%d h ago", int(d/time.Hour))
default:
return t.UTC().Format("2006-01-02")
}
}
// DNSStatusTag is the tag of one published record, from the status the DNS
// checker reports (ok, warn, error or unknown): green when it is published and
// right, amber when it is published but weak, red when it is missing or wrong.
// found says whether the resolver returned anything at all — an error with a
// record in DNS is a mismatch, without one the record is missing.
func DNSStatusTag(status string, found bool) Tag {
switch status {
case "ok":
return Tag{Status: "published", Label: "published"}
case "warn":
return Tag{Status: "warn", Label: "weak"}
case "error":
if found {
return Tag{Status: "mismatch", Label: "mismatch"}
}
return Tag{Status: "fail", Label: "missing"}
}
return Tag{Status: "unknown", Label: "not checked"}
}
// tagLevel is the level (ok, warn or fail) a tag's status stands for. A status
// that says nothing — unknown, off — is a warning, never a pass.
func tagLevel(status string) string {
switch status {
case "ok", "published", "running", "sent":
return LevelOK
case "error", "fail", "mismatch", "bounced":
return LevelFail
}
return LevelWarn
}
// appsPhrase counts the applications a domain has, as a sentence needs them:
// "its application", "both applications", "all 3 applications"; "" for none.
func appsPhrase(n int) string {
switch n {
case 0:
return ""
case 1:
return "its application"
case 2:
return "both applications"
}
return "all " + strconv.Itoa(n) + " applications"
}
// ---- The records of a domain
// DNSCheck is what the DNS checker found for one record: its status (ok, warn,
// error or unknown), the sentence that explains it and the values it found.
type DNSCheck struct {
Status string
Detail string
Found []string
}
// applyCheck puts a check's verdict on a record: the status tag and, when the
// record is not as it should be, what DNS holds now and why that is wrong. A
// record DNS does not hold at all has nothing to compare, so the explanation
// goes under the value to publish instead. help is the record's standing note
// under that value.
func applyCheck(r *Record, c DNSCheck, standing Text, mismatch Text) {
found := len(c.Found) > 0
r.Status = DNSStatusTag(c.Status, found)
r.ValueHelp = standing
if c.Status == "ok" {
return
}
if found {
r.InDNS = strings.Join(c.Found, " ")
r.Problem = Rich(c.Detail, mismatch)
return
}
if c.Detail != "" {
r.ValueHelp = append(Plain(c.Detail+" "), standing...)
}
}
// DKIMRecord is the DKIM record to publish: the key is not a secret, and the
// selector says which one it is.
func DKIMRecord(selector, host, value string, c DNSCheck) Record {
r := Record{Name: "DKIM", Note: Rich("Not a secret · selector ", Code(selector)),
Host: host, Type: "TXT", Value: value, ValueRows: 3}
applyCheck(&r, c, nil, nil)
return r
}
// SPFRecord is the SPF record to publish. The check is shallow — it does not
// follow include: — which a mismatch says.
func SPFRecord(domain, value string, c DNSCheck) Record {
r := Record{Name: "SPF", Note: Plain("Merge into the existing record — never publish a second one"),
Host: domain, Type: "TXT", Value: value}
applyCheck(&r, c, nil, Rich(" Shallow check: ", Code("include:"), " is not followed."))
return r
}
// DMARCRecordInput is what a DMARC record needs besides its check: where the
// reports go in words (Source, "admin's default") and the page that changes it,
// the page of the reports themselves ("" when the panel has none), and whether
// the report address is on the sending domain with nobody to read it there.
type DMARCRecordInput struct {
Host string
Value string
Check DNSCheck
Source string
SettingsHref string
ReportsHref string
SameDomain bool
}
// DMARCRecord is the DMARC record to publish: p=none changes nothing about
// delivery, so the standing note says how to tighten it.
func DMARCRecord(in DMARCRecordInput) Record {
r := Record{Name: "DMARC", Host: in.Host, Type: "TXT", Value: in.Value,
Note: Rich("Report address: ", Link(in.SettingsHref+"#reports", in.Source))}
reports := Rich("reports")
if in.ReportsHref != "" {
reports = Rich(Link(in.ReportsHref, "reports"))
}
standing := Rich(Code("p=none"), " does not affect delivery. Tighten to ", Code("quarantine"), ", then ", Code("reject"),
", once the ", reports, " look clean.")
if in.SameDomain {
standing = append(standing, Inline{Text: " The report address is on this sending domain, where SelfPost receives no mail — use a mailbox on another domain."})
}
applyCheck(&r, in.Check, standing, nil)
return r
}
// ReportAuthRecord is the record the domain that receives the reports must
// publish when it is not the sending domain.
func ReportAuthRecord(host, value string, c DNSCheck) Record {
r := Record{Name: "Report authorization", Note: Plain("Needed because the address is on another domain than the senders"),
Host: host, Type: "TXT", Value: value}
applyCheck(&r, c, nil, nil)
return r
}
// ---- Outbound domains
// OutDomains is the data of the list. The global role adds and deletes domains
// (CanManage); a domain administrator sees the list alone, so the add box is
// absent for them and the table is the first box.
type OutDomains struct {
Meta
Head Head
Flash *Flash
CanManage bool
Add Box
AddHelp Text
// AddName is what was typed into the form, shown again when it is refused.
AddName string
List Box
// Days is the length of the send window of the "N days" column.
Days int
Rows []OutDomainRow
Empty EmptyState
Legend Foot
}
// OutDomainRow is one line of the Domains table: the domain, the verdict of
// each DNS check as a tag, the DKIM selector, the applications and the mail of
// the window (FormatActivity). DeleteHref is empty when the viewer may not
// delete.
type OutDomainRow struct {
Name string
Href string
DeleteHref string
DNS []Tag
Selector string
Apps int
Activity string
}
// NewOutDomains builds the list for a viewer. canManage is the global role;
// days is the send window of the last column.
func NewOutDomains(m Meta, canManage bool, days int) *OutDomains {
m.Title, m.Section, m.Page = "Outbound domains", "outbound", "domains"
p := &OutDomains{
Meta: m,
Head: Head{
Kicker: "Outbound",
Title: "Outbound domains",
Lead: Plain("Domains this relay signs and sends for. Each gets a DKIM key and its own application logins."),
},
CanManage: canManage,
Days: days,
List: Box{No: "01", Title: "Domains"},
Empty: EmptyState{Icon: "ti-world-upload", Text: Plain("No outbound domains yet.")},
Legend: Foot{Bare: true,
Text: Plain("Each tag is one check. Green — published and correct; amber — published but weak; red — missing or wrong.")},
}
if canManage {
p.Add = Box{No: "01", Title: "Add a domain"}
p.List.No = "02"
p.AddHelp = Rich("A DKIM key is generated at once; the next page shows the three DNS records to publish. Moving a domain from another SelfPost? ",
Link("/server/backup#import", "Import it"), " instead — the key and passwords come across.")
p.Empty.Text = Plain("No outbound domains yet. Add the first one above.")
}
p.List.End = Plain("DNS cached a few minutes")
return p
}
// WithRows sets the table and counts it in the box head.
func (p *OutDomains) WithRows(rows []OutDomainRow) *OutDomains {
p.Rows = rows
p.List.End = Plain(plural(int64(len(rows)), "domain", "domains") + " · DNS cached a few minutes")
return p
}
// WithResult shows the result of the last action, or the refusal of the add
// form (with what was typed kept in the field), between the head and the first
// box.
func (p *OutDomains) WithResult(flash, formErr, typed string) *OutDomains {
p.AddName = typed
p.Flash = resultFlash(flash, formErr)
return p
}
// resultFlash is the flash of a page that shows either an error or a success.
func resultFlash(flash, formErr string) *Flash {
switch {
case formErr != "":
return &Flash{Error: true, Text: Plain(formErr)}
case flash != "":
return &Flash{Text: Plain(flash)}
}
return nil
}
// ---- One outbound domain
// OutDomain is the data of a domain's page: the DNS records to publish and
// what they check as, the applications, and how to connect. The side menu leads
// to the boxes, to the pages about the same domain and to its settings.
type OutDomain struct {
Meta
Head Head
Flash *Flash
id int64
name string
DNS Box
Records []Record
Apps Box
// Days is the length of the send window of the "N days" column.
Days int
AppRows []OutAppRow
AppsEmpty EmptyState
AppsLegend Foot
Connection Box
ConnFacts []Fact
logHref, dmarcHref string
limitTag *Tag
canDelete bool
}
// OutAppRow is one line of the Applications table. Senders are the addresses
// the login may send as (*@example.org for the whole domain); Limits are tags,
// the application's own rate limit or "domain" and its client IP allow-list.
// Edit leads to the application's page, where a new password and deleting it
// are one click away: both are POSTs, and a row of a table has no control for
// those (only links).
type OutAppRow struct {
Login string
Senders []string
Activity string
Limits []Tag
Edit string
}
// NewOutDomain builds the page of a domain. canDelete is the global role: it
// adds the "Delete domain" entry to the menu.
func NewOutDomain(m Meta, id int64, name string, canDelete bool) *OutDomain {
m.Title, m.Section, m.Page = name+" · Outbound domains", "outbound", "domains"
href := DomainHref(id)
return &OutDomain{
Meta: m,
Head: Head{
Crumbs: []Crumb{{Href: outboundDomains, Label: "Outbound domains"}},
Title: name,
Actions: []Action{
{Label: "Re-check DNS", Icon: "ti-refresh", Post: href + "/dns-recheck"},
{Label: "Add application", Icon: "ti-plus", Href: href + "/applications/new", Primary: true},
},
},
id: id, name: name, canDelete: canDelete,
DNS: Box{No: "01", Title: "DNS records", ID: "dns",
Help: &HelpLink{Href: "/help#dns", Title: "What to publish and why"}},
Apps: Box{No: "02", Title: "Applications", ID: "apps", End: Plain("SASL logins that may send as this domain"),
Help: &HelpLink{Href: "/help#apps", Title: "What an application is"}},
AppsEmpty: EmptyState{Icon: "ti-apps", Text: Plain("No applications yet. Add one to let a program send as this domain.")},
Connection: Box{No: "03", Title: "Connection", ID: "connection", End: Plain("The same for every domain")},
}
}
// WithStats puts the mail of the window under the title.
func (p *OutDomain) WithStats(total, peak int64, avg string, days int) *OutDomain {
p.Head.Lead = StatsLead(total, peak, avg, days)
p.Days = days
return p
}
// WithRecords sets the records to publish, in order, and derives from their
// tags what the page says about them: the stamp (the worst record sets it, and
// it counts the records that are not fine), the count in the side menu and, as
// the box's end slot, how long ago they were checked ("" says nothing).
func (p *OutDomain) WithRecords(records []Record, checked string) *OutDomain {
p.Records = records
if checked != "" {
p.DNS.End = Plain("Checked " + checked)
}
level, bad := LevelOK, 0
for _, r := range records {
switch l := tagLevel(r.Status.Status); {
case l == LevelOK:
case l == LevelFail:
level, bad = LevelFail, bad+1
default:
if level == LevelOK {
level = LevelWarn
}
bad++
}
}
word := map[string]string{LevelOK: "OK", LevelWarn: "WARN", LevelFail: "FAIL"}[level]
count := bad
if level == LevelOK {
count = len(records)
}
if len(records) > 0 {
p.Head.Postmark = &Postmark{Top: "DNS", Word: word,
Bottom: fmt.Sprintf("%d OF %d RECORDS", count, len(records)), Level: level}
}
return p
}
// WithApplications sets the Applications table.
func (p *OutDomain) WithApplications(rows []OutAppRow) *OutDomain {
p.AppRows = rows
return p
}
// WithConnection says how to connect: the server's name and whether the
// submission port (587) is on beside the implicit-TLS one (465).
func (p *OutDomain) WithConnection(host string, submission bool) *OutDomain {
if host == "" {
host = "(SELFPOST_HOSTNAME is not set)"
}
p.ConnFacts = []Fact{
{Label: "Server", Value: Plain(host), Mono: true},
{Label: "Port 465", Value: Plain("SSL/TLS (implicit)")},
}
if submission {
p.ConnFacts = append(p.ConnFacts, Fact{Label: "Port 587", Value: Plain("STARTTLS (submission)")})
}
p.ConnFacts = append(p.ConnFacts, Fact{Label: "Authentication", Value: Plain("Application login, required on every port")})
return p
}
// WithSeeAlso adds the pages about the same domain to the menu: its entries in
// the log, and in DMARC reports when the domain has them ("" leaves one out).
func (p *OutDomain) WithSeeAlso(logHref, dmarcHref string) *OutDomain {
p.logHref, p.dmarcHref = logHref, dmarcHref
return p
}
// WithRateLimit says in the menu what the domain's rate limit is: "auto",
// "manual", or nothing when it has none of its own.
func (p *OutDomain) WithRateLimit(auto, manual bool) *OutDomain {
switch {
case auto:
p.limitTag = &Tag{Status: "ok", Label: "auto"}
case manual:
p.limitTag = &Tag{Status: "ok", Label: "manual"}
}
return p
}
// WithResult shows the result of the last action between the head and the boxes.
func (p *OutDomain) WithResult(flash string) *OutDomain {
p.Flash = resultFlash(flash, "")
return p
}
// Menu is the side menu: this page's boxes with their counts, the pages about
// the same domain, and the entries of its settings.
func (p *OutDomain) Menu() SideMenu {
here := MenuGroup{Label: "This domain", Items: []MenuItem{
{Label: "DNS records", Icon: "ti-world-check", Href: "#dns", Active: true, Tag: p.dnsCount()},
{Label: "Applications", Icon: "ti-apps", Href: "#apps"},
{Label: "Connection", Icon: "ti-plug", Href: "#connection"},
}}
if n := len(p.AppRows); n > 0 {
here.Items[1].Tag = &Tag{Label: strconv.Itoa(n)}
}
menu := SideMenu{Groups: []MenuGroup{here}}
var see []MenuItem
if p.logHref != "" {
see = append(see, MenuItem{Label: "Log for this domain", Icon: "ti-list-details", Href: p.logHref})
}
if p.dmarcHref != "" {
see = append(see, MenuItem{Label: "DMARC reports", Icon: "ti-report-analytics", Href: p.dmarcHref})
}
if len(see) > 0 {
menu.Groups = append(menu.Groups, MenuGroup{Label: "See also", Items: see})
}
settings := DomainHref(p.id) + "/settings"
rare := MenuGroup{Label: "Rarely changed", Items: []MenuItem{
{Label: "Report address", Icon: "ti-mail-forward", Href: settings + "#reports"},
{Label: "Rate limit", Icon: "ti-gauge", Href: settings + "#limit", Tag: p.limitTag},
{Label: "Export", Icon: "ti-file-export", Href: settings + "#export"},
}}
if p.canDelete {
rare.Items = append(rare.Items, MenuItem{Label: "Delete domain", Icon: "ti-trash", Href: DomainHref(p.id) + "/delete", Danger: true})
}
menu.Groups = append(menu.Groups, rare)
return menu
}
// dnsCount is the tag beside "DNS records" in the menu: how many records are
// not fine, in the colour of the worst. Nothing when all are.
func (p *OutDomain) dnsCount() *Tag {
bad, level := 0, LevelOK
for _, r := range p.Records {
switch l := tagLevel(r.Status.Status); {
case l == LevelFail:
bad, level = bad+1, LevelFail
case l == LevelWarn:
bad++
if level == LevelOK {
level = LevelWarn
}
}
}
if bad == 0 {
return nil
}
return &Tag{Status: level, Label: strconv.Itoa(bad)}
}
// ---- Settings of an outbound domain
// OutDomainSettings is the data of Domain settings: where DMARC reports go,
// the export of the domain, its rate limit and, for the global role, deleting
// it. Each box is its own form with its own POST.
type OutDomainSettings struct {
Meta
Head Head
Flash *Flash
Reports Box
ReportsAction string
RuaOptions []Option
RuaSelected string
RuaCustom string
RuaHelp Text
Export Box
ExportAction string
Encrypt bool
PasswordMin int
Limit Box
Rate DomainRateLimit
// LimitAction and the two actions beside it are the POST addresses of the
// rate-limit form: save, recalculate and remove.
LimitAction string
RecalcAction string
LimitNote Text
CanDelete bool
Delete Box
DeleteText Text
DeleteLink Action
}
// DomainRateLimit is the domain's rate limit as its form shows it. Active says
// that the domain has a limit of its own in force; Auto that it is worked out
// from the statistics. The numbers are the strings the fields hold.
type DomainRateLimit struct {
Active bool
Auto bool
MaxMessages string
Window string
Multiplier string
// Computed is the limit auto mode arrived at (messages per window), "" when
// there was no traffic to compute it from; Peak is the busiest hour of the
// statistics window and Updated when the limit was last recalculated.
Computed string
Peak int64
Updated string
// Level 1: the per-client-IP backstop nothing below may exceed.
L1Messages int
L1Window int
// The multiplier's range, as the field states it.
MinMultiplier string
MaxMultiplier string
DefaultMultiplier string
}
// AutoHelp is the line under the multiplier: what the limit computes to now.
func (r DomainRateLimit) AutoHelp() Text {
return autoLimitHelp(r.Computed, r.L1Window, r.Peak, r.Updated)
}
// autoLimitHelp says what an automatic limit works out to: the messages per
// window it computed from the statistics, the busiest hour it was computed
// against and when. computed is "" before there was any traffic to compute it
// from.
func autoLimitHelp(computed string, window int, peak int64, updated string) Text {
if computed == "" {
t := Rich("No limit is computed yet: zero traffic keeps auto inactive until messages are sent.")
if peak > 0 {
t = append(t, Inline{Text: " Peak was " + FormatCount(peak) + " msg/h."})
}
return t
}
t := Rich("Computed limit: ", Strong(computed), " messages / "+strconv.Itoa(window)+" s. Peak was "+FormatCount(peak)+" msg/h.")
if updated != "" {
t = append(t, Inline{Text: " Last recalculated " + updated + "."})
}
return t
}
// NewOutDomainSettings builds the page of a domain's settings. canDelete is the
// global role; apps is how many applications deleting the domain would take.
func NewOutDomainSettings(m Meta, id int64, name string, apps int, canDelete bool) *OutDomainSettings {
m.Title, m.Section, m.Page = "Settings · "+name, "outbound", "domains"
href := DomainHref(id)
p := &OutDomainSettings{
Meta: m,
Head: Head{
Crumbs: outboundCrumbs(id, name),
Title: "Domain settings",
Lead: Plain("The things set once and rarely touched: where DMARC reports go, the sending ceiling, moving the domain elsewhere."),
},
Reports: Box{No: "01", Title: "DMARC report address", ID: "reports"},
ReportsAction: href + "/settings/reports",
Export: Box{No: "03", Title: "Export domain", ID: "export"},
ExportAction: href + "/settings/export",
Encrypt: true,
Limit: Box{No: "02", Title: "Rate limit", ID: "limit",
Help: &HelpLink{Href: "/help#limits", Title: "Levels 1 and 2"}},
LimitAction: href + "/settings/ratelimit",
RecalcAction: href + "/settings/ratelimit/recalc",
CanDelete: canDelete,
}
if canDelete {
text := "Removes the DKIM key"
if n := appsPhrase(apps); n != "" {
text += " and " + n
}
p.Delete = Box{No: "04", Title: "Delete domain", Variant: BoxDanger}
p.DeleteText = Plain(text + ". Asks once more before doing it.")
p.DeleteLink = Action{Label: "Delete " + name + "…", Icon: "ti-trash", Href: href + "/delete", Danger: true}
}
return p
}
// WithReportAddress sets the report-address form: the choices (the value of an
// option is what the handler reads), the one selected, the custom address
// typed, and the sentence under the select.
func (p *OutDomainSettings) WithReportAddress(options []Option, selected, custom string, help Text) *OutDomainSettings {
p.RuaOptions, p.RuaSelected, p.RuaCustom, p.RuaHelp = options, selected, custom, help
return p
}
// WithExport sets the shortest password the export accepts.
func (p *OutDomainSettings) WithExport(passwordMin int) *OutDomainSettings {
p.PasswordMin = passwordMin
return p
}
// WithRateLimit sets the rate-limit form and the tag that says whether the
// limit is in force.
func (p *OutDomainSettings) WithRateLimit(r DomainRateLimit) *OutDomainSettings {
p.Rate = r
if r.Active {
p.Limit.End = Rich(TagOf("ok", "active"))
} else {
p.Limit.End = Rich(TagOf("off", "inactive"))
}
p.LimitNote = Rich("Applies to every sender on this domain and cannot exceed level 1 (",
strconv.Itoa(r.L1Messages)+" messages / "+strconv.Itoa(r.L1Window)+" s per client IP, set in ", Code(".env"),
"). An application may carry its own limit above or below this one.")
return p
}
// WithResult shows the result of the last action, or its refusal, between the
// head and the boxes.
func (p *OutDomainSettings) WithResult(flash, formErr string) *OutDomainSettings {
p.Flash = resultFlash(flash, formErr)
return p
}
// ---- Delete an outbound domain
// OutDomainDelete is the data of the confirmation before a domain is deleted:
// what goes with it, the one button that does it, and the way out.
type OutDomainDelete struct {
Meta
Head Head
Danger Box
Intro Text
Consequences []Text
Note Text
Action string
Confirm string
KeepHref string
Moving Box
MovingText Text
}
// NewOutDomainDelete builds the page. logins are the applications the domain
// has: they are named, because they go with it.
func NewOutDomainDelete(m Meta, id int64, name string, logins []string) *OutDomainDelete {
m.Title, m.Section, m.Page = "Delete "+name, "outbound", "domains"
href := DomainHref(id)
p := &OutDomainDelete{
Meta: m,
Head: Head{Crumbs: outboundCrumbs(id, name), Title: "Delete " + name},
Danger: Box{Icon: "ti-alert-triangle", Title: "This cannot be undone", Variant: BoxDanger},
Intro: Rich("Deleting ", Strong(name), " will:"),
Consequences: []Text{Rich("permanently delete its DKIM signing key;")},
Note: Plain("The published DKIM record becomes invalid — remove it from DNS unless you plan to re-add the domain. Inbound domains are not touched."),
Action: href + "/delete",
Confirm: "Delete " + name,
KeepHref: href,
Moving: Box{Icon: "ti-file-export", Title: "Moving it instead?"},
MovingText: Rich(Link(href+"/settings#export", "Export the domain"),
" first. The file carries the key and the application passwords, so the new instance needs no DNS change."),
}
if n := appsPhrase(len(logins)); n != "" {
item := Rich("delete ", Strong(n), " (")
for i, l := range logins {
if i > 0 {
item = append(item, Inline{Text: ", "})
}
item = append(item, Mono(l))
}
item = append(item, Inline{Text: ") with their credentials and sender bindings;"})
p.Consequences = append(p.Consequences, item)
}
p.Consequences = append(p.Consequences, Rich("reload OpenDKIM so the domain is no longer signed."))
return p
}
+263
View File
@@ -0,0 +1,263 @@
package view
// The data of the application pages: the form that adds or edits an application
// (templates/out_app.html, mockup out-app.html) and the page that shows its
// password once (out_app_created.html, mockup out-app-created.html).
//
// The form is one POST: who the application may send as, which client IPs may
// use it and its rate limit are checked and saved together or not at all.
import (
"strconv"
"strings"
)
// Address modes of an application, the values of the sender select
// (store.AddressMode*).
const (
AddressWildcard = "wildcard"
AddressList = "list"
)
// Rate-limit choices of an application, the values of the limit select.
const (
LimitDomain = "domain"
LimitManual = "manual"
LimitAuto = "auto"
)
// OutApp is the data of the application form. It adds an application when
// Login is not fixed (the empty form) and edits one when it is. The form fields
// hold what was stored, or what was just submitted when the form is shown again
// with a refusal.
type OutApp struct {
Meta
Head Head
Flash *Flash
// Action is where the form is posted; Submit is the main button's label.
// Cancel leads back to the domain. Recalc, set only while the limit is
// automatic, is the POST address of its "Recalculate" button.
Action string
Submit string
Cancel string
Recalc string
Sender Box
Login string
LoginFixed bool
LoginHelp Text
Modes []Option
Mode string
Addresses string
Placeholder string
IPs Box
IPRestrict bool
AllowedIPs string
Limit Box
LimitModes []Option
LimitMode string
MaxMessages string
Window string
Multiplier string
Rate AppRateLimit
recalc string
}
// AppRateLimit is the context of an application's rate-limit fields: the
// ceiling nothing may exceed (level 1), the range of the multiplier, what the
// domain's own limit is, and what the automatic limit has computed.
type AppRateLimit struct {
L1Messages int
L1Window int
MinMultiplier string
MaxMultiplier string
DefaultMultiplier string
// DomainLimit is the limit the application falls back to ("288 / h"), ""
// when the domain has none of its own.
DomainLimit string
// Computed, Peak and Updated are the automatic limit as DomainRateLimit has
// them; Computed is "" for an application that has none yet.
Computed string
Peak int64
Updated string
}
// AutoHelp is the line under the multiplier: what the limit computes to now.
func (r AppRateLimit) AutoHelp() Text {
return autoLimitHelp(r.Computed, r.L1Window, r.Peak, r.Updated)
}
// OutAppState is what is stored about the application, which the tags in the
// box heads and the Recalculate button report (the fields may hold something
// that was not saved): whether its client IP allow-list is on, whether it has a
// rate limit of its own, and whether that limit is automatic.
type OutAppState struct {
IPs bool
Limit bool
Auto bool
}
// OutAppForm is the values of the application form.
type OutAppForm struct {
Login string
Mode string
Addresses string
IPRestrict bool
AllowedIPs string
LimitMode string
MaxMessages string
Window string
Multiplier string
}
// NewOutApp builds the form of a domain's application. login is the stored
// application's login, or "" for the form that adds one.
func NewOutApp(m Meta, domainID int64, domain, login string) *OutApp {
href := DomainHref(domainID)
crumbs := append(outboundCrumbs(domainID, domain), Crumb{Label: "Applications"})
m.Section, m.Page = "outbound", "domains"
p := &OutApp{
Cancel: href,
Sender: Box{No: "01", Title: "Sender"},
IPs: Box{No: "02", Title: "Client IP allow-list"},
Limit: Box{No: "03", Title: "Rate limit"},
Modes: []Option{
{Value: AddressWildcard, Label: "Any address of the domain"},
{Value: AddressList, Label: "Specific addresses (list)"},
},
LimitModes: []Option{
{Value: LimitDomain, Label: "Use the domain limit"},
{Value: LimitManual, Label: "Manual"},
{Value: LimitAuto, Label: "Auto (from statistics)"},
},
Placeholder: "alerts@" + domain,
}
if login == "" {
m.Title = "New application · " + domain
p.Head = Head{Crumbs: crumbs, Title: "New application",
Lead: Plain("A password is generated when it is created and shown once.")}
p.Action = href + "/applications/new"
p.Submit = "Create application"
p.LoginHelp = Rich("Unique across domains; letters, digits, ", Code("."), " ", Code("-"), " ", Code("_"), ".")
} else {
m.Title = login + " · " + domain
p.Head = Head{Crumbs: crumbs, Title: login, Mono: true}
p.Submit = "Save application"
p.LoginFixed = true
p.LoginHelp = Plain("Fixed: add a new application to use another login.")
}
p.Meta = m
return p
}
// WithApplication points an edit form at its application: the POST address, the
// head's buttons (a new password and delete, each asking first) and the mail of
// the statistics window under the title.
func (p *OutApp) WithApplication(domainID, id int64, total, peak int64, avg string, days int) *OutApp {
own := DomainHref(domainID) + "/applications/" + strconv.FormatInt(id, 10)
p.Action = own
p.Head.Lead = StatsLead(total, peak, avg, days)
p.Head.Actions = []Action{
{Label: "New password", Icon: "ti-key", Post: own + "/password",
Confirm: "Generate a new password for " + p.Head.Title + "? The current password stops working immediately."},
{Label: "Delete", Icon: "ti-trash", Post: own + "/delete", Danger: true,
Confirm: "Delete application " + p.Head.Title + "? Its credentials stop working immediately."},
}
p.recalc = own + "/ratelimit/recalc"
return p
}
// WithForm fills the fields; state is what is stored.
func (p *OutApp) WithForm(f OutAppForm, state OutAppState, rate AppRateLimit) *OutApp {
p.Login, p.Mode, p.Addresses = f.Login, f.Mode, f.Addresses
p.IPRestrict, p.AllowedIPs = f.IPRestrict, f.AllowedIPs
p.LimitMode, p.MaxMessages, p.Window, p.Multiplier = f.LimitMode, f.MaxMessages, f.Window, f.Multiplier
p.Rate = rate
if state.IPs {
p.IPs.End = Rich(TagOf("ok", "active"))
} else {
p.IPs.End = Rich(TagOf("off", "off"))
}
if state.Limit {
p.Limit.End = Rich(TagOf("ok", "active"))
} else {
p.Limit.End = Rich(TagOf("off", "inactive"))
}
if state.Auto && p.recalc != "" {
p.Recalc = p.recalc
}
return p
}
// LimitHelp is the line under the rate-limit fields: what the limit overrides.
func (p *OutApp) LimitHelp() Text {
fall := "the domain limit, if it has one, or level 1"
if p.Rate.DomainLimit != "" {
fall = "the domain limit (" + p.Rate.DomainLimit + ")"
}
return Rich("Overrides ", fall, " for this application; may be higher or lower, never above level 1.")
}
// WithResult shows the refusal of the form, or the result of an action, between
// the head and the form.
func (p *OutApp) WithResult(flash, formErr string) *OutApp {
p.Flash = resultFlash(flash, formErr)
return p
}
// ---- The password, shown once
// OutAppCreated is the page that follows creating an application or issuing it
// a new password: the login and the password, which are not stored and are not
// shown again, and how to connect with them. It is the response to a POST and
// has no address of its own.
type OutAppCreated struct {
Meta
Head Head
Credential Box
Login CopyField
Password CopyField
Done Foot
Connect Box
Facts []Fact
}
// NewOutAppCreated builds the page. fresh says the application was created just
// now (it had no password before). host is the server's name, submission whether
// port 587 is on, and senders what the login may send as.
func NewOutAppCreated(m Meta, domainID int64, domain, login, password string, fresh bool, host string, submission bool, senders []string) *OutAppCreated {
m.Title, m.Section, m.Page = "New password · "+login, "outbound", "domains"
help := Plain("The previous password stopped working when this one was generated. If this one is lost, generate another.")
if fresh {
help = Plain("If this one is lost, generate another from the application's page.")
}
if host == "" {
host = "(SELFPOST_HOSTNAME is not set)"
}
port := "465 SSL/TLS"
if submission {
port += " · 587 STARTTLS"
}
return &OutAppCreated{
Meta: m,
Head: Head{Crumbs: append(outboundCrumbs(domainID, domain), Crumb{Label: "Applications"}),
Title: "New password for", Code: login},
Credential: Box{Icon: "ti-eye", Title: "Shown once", End: Plain("Not stored — copy it now"), Variant: BoxCredential},
Login: CopyField{Label: "Login", Value: login},
Password: CopyField{Label: "Password", Value: password, Help: help},
Done: Foot{Button: &Action{Label: "Done — back to " + domain, Href: DomainHref(domainID), Primary: true}},
Connect: Box{Icon: "ti-plug", Title: "Connect with"},
Facts: []Fact{
{Label: "Server", Value: Plain(host), Mono: true},
{Label: "Port", Value: Plain(port)},
{Label: "May send as", Value: Plain(strings.Join(senders, ", ")), Mono: true},
},
}
}
+485
View File
@@ -0,0 +1,485 @@
package view
import (
"strings"
"testing"
"time"
)
// ---- Outbound domains
// The global role adds and deletes domains; a domain administrator sees the
// list alone, so the page has no add box, no Delete link, and numbers the table
// box 01.
func TestOutDomainsOffersAddAndDeleteToTheGlobalRoleOnly(t *testing.T) {
out := renderSignedIn(t, "out-domains", outDomainsFixture())
pageHas(t, "Outbound domains", out,
`<h1 class="title is-3">Outbound domains</h1>`, `<h2>Add a domain</h2>`, `action="/outbound/domains"`, `name="name"`,
`href="/server/backup#import"`, `<th>30 days</th>`, `>Delete</a>`, `href="/outbound/domains/2/delete"`,
`3 domains · DNS cached a few minutes`, "1 284 msg · peak 96/h", `class="sp-muted sp-mono">sp2025<`,
`<span class="tag is-danger is-light">SPF</span>`, `<span class="tag is-warning is-light">DMARC</span>`,
)
p := NewOutDomains(Meta{User: "ops", HasOutbound: true}, false, 30).WithRows([]OutDomainRow{
{Name: "example.org", Href: "/outbound/domains/1", Selector: "sp2026", Apps: 1, Activity: "—",
DNS: []Tag{{Status: "ok", Label: "DKIM"}}},
})
out = renderSignedIn(t, "out-domains", p)
pageHas(t, "Outbound domains", out, `<span class="sp-no">01</span><h2>Domains</h2>`, `1 domain · DNS cached`)
pageLacks(t, "Outbound domains", out, `Add a domain`, `name="name"`, `>Delete</a>`, `/delete`)
}
func TestOutDomainsEmptyAndRefused(t *testing.T) {
p := NewOutDomains(admin(), true, 30).WithRows(nil).WithResult("", "That domain is already configured.", "example.org")
out := renderSignedIn(t, "out-domains", p)
pageHas(t, "Outbound domains", out, `No outbound domains yet. Add the first one above.`,
`<div class="notification is-danger is-light">`, `That domain is already configured.`, `value="example.org"`, `0 domains`)
pageLacks(t, "Outbound domains", out, `<table`)
// the flash sits between the head and the first box
if strings.Index(out, `class="notification`) < strings.Index(out, `class="sp-head"`) ||
strings.Index(out, `class="notification`) > strings.Index(out, `class="box"`) {
t.Error("the flash is not between page_head and the first box")
}
out = renderSignedIn(t, "out-domains", NewOutDomains(admin(), true, 30).WithResult("Domain deleted.", "", ""))
pageHas(t, "Outbound domains", out, `<div class="notification is-success is-light">`, `Domain deleted.`)
}
func TestOutDomainsEscapesWhatPeopleTyped(t *testing.T) {
p := NewOutDomains(admin(), true, 30).WithRows([]OutDomainRow{
{Name: `<script>alert(1)</script>`, Href: "/outbound/domains/1", Selector: `"><img src=x>`},
}).WithResult("", "bad", `"><i>`)
out := renderSignedIn(t, "out-domains", p)
pageLacks(t, "Outbound domains", out, `<script>alert(1)`, `<img src=x>`, `<i>`)
}
// ---- One outbound domain
// The stamp, the box head and the menu say the same thing about the records:
// the worst one colours the stamp, the number counts those that are not fine.
func TestOutDomainStampFollowsTheRecords(t *testing.T) {
out := renderSignedIn(t, "out-domain", outDomainFixture())
pageHas(t, "Domain", out,
`<h1 class="title is-3">example.org</h1>`, `class="sp-postmark sp-fail"`, `<b>FAIL</b>`, `1 OF 3 RECORDS`,
`<a href="/outbound/domains">Outbound domains</a> /`,
"1 284 messages in 30 days · peak 96 msg/h · average 1.8 msg/h",
`Checked 3 min ago`, `<span class="tag is-danger is-light">1</span>`, // the menu's count
`action="/outbound/domains/1/dns-recheck"`, `href="/outbound/domains/1/applications/new"`,
`<span class="tag is-success is-light">auto</span>`,
)
ok := func(status string) Record { return Record{Name: "X", Status: Tag{Status: status}} }
for _, c := range []struct {
records []Record
level, word, over string
menu bool
}{
{[]Record{ok("published"), ok("published"), ok("published")}, LevelOK, "OK", "3 OF 3 RECORDS", false},
{[]Record{ok("published"), ok("warn"), ok("unknown")}, LevelWarn, "WARN", "2 OF 3 RECORDS", true},
{[]Record{ok("warn"), ok("mismatch"), ok("fail"), ok("published")}, LevelFail, "FAIL", "3 OF 4 RECORDS", true},
} {
p := outDomainFixture().WithRecords(c.records, "")
if m := p.Head.Postmark; m == nil || m.Level != c.level || m.Word != c.word || m.Bottom != c.over || m.Top != "DNS" {
t.Errorf("records %v are stamped %+v, want %s %s", c.records, m, c.word, c.over)
}
if got := p.dnsCount() != nil; got != c.menu {
t.Errorf("the menu count for %v is shown: %v, want %v", c.records, got, c.menu)
}
}
// No time to report: the box says nothing about when it was checked.
if p := NewOutDomain(admin(), 1, "example.org", true).WithRecords(nil, ""); p.DNS.End != nil || p.Head.Postmark != nil {
t.Error("a domain without records has a stamp or a check time")
}
}
func TestOutDomainRecordsSayWhatDNSHolds(t *testing.T) {
out := renderSignedIn(t, "out-domain", outDomainFixture())
pageHas(t, "Domain", out,
`<h3>DKIM</h3>`, `<span class="tag is-success is-light">published</span>`, `selector <code>sp2026</code>`,
`sp2026._domainkey.example.org`, `<textarea class="textarea" rows="3" readonly>`,
`<h3>SPF</h3>`, `<span class="tag is-danger is-light">mismatch</span>`, `v=spf1 ip4:203.0.113.25 ~all`,
`In DNS now`, `v=spf1 include:_spf.google.com ~all`, `Shallow check: <code>include:</code> is not followed.`,
`<h3>DMARC</h3>`, `Report address: <a href="/outbound/domains/1/settings#reports">admin&#39;s default</a>`,
`<code>p=none</code> does not affect delivery.`, `<a href="/outbound/dmarc/domains/1">reports</a>`,
)
if got := strings.Count(out, `In DNS now`); got != 1 {
t.Errorf("%d records show what DNS holds, want the mismatching one alone", got)
}
if got := strings.Count(out, `data-copy`); got != 6 {
t.Errorf("%d copy buttons, want a host and a value for each of the three records", got)
}
// A record DNS does not hold has nothing to compare: the explanation goes
// under the value to publish. A weak one is amber.
records := []Record{
DKIMRecord("sp2026", "sp2026._domainkey.example.org", "v=DKIM1; p=KEY", DNSCheck{Status: "error", Detail: "No DKIM record was found."}),
SPFRecord("example.org", "v=spf1 -all", DNSCheck{Status: "warn", Detail: "The record ends in ~all.", Found: []string{"v=spf1 ~all"}}),
DMARCRecord(DMARCRecordInput{Host: "_dmarc.example.org", Value: "v=DMARC1; p=none", Check: DNSCheck{Status: "unknown", Detail: "DNS could not be reached."},
Source: "none", SettingsHref: "/outbound/domains/1/settings", SameDomain: true}),
ReportAuthRecord("_report._dmarc.hub.example", "v=DMARC1;", DNSCheck{Status: "error", Detail: "Not authorized."}),
}
out = renderSignedIn(t, "out-domain", outDomainFixture().WithRecords(records, "just now"))
pageHas(t, "Domain", out,
`<span class="tag is-danger is-light">missing</span>`, `No DKIM record was found.`,
`<span class="tag is-warning is-light">weak</span>`, `The record ends in ~all.`, `v=spf1 ~all`,
`<span class="tag is-light">not checked</span>`, `DNS could not be reached.`, `where SelfPost receives no mail`,
`<h3>Report authorization</h3>`, `_report._dmarc.hub.example`, `Needed because the address is on another domain than the senders`,
`4 OF 4 RECORDS`,
)
pageLacks(t, "Domain", out, `<a href="/outbound/dmarc/domains/1">reports</a>`) // no page of reports was given
}
func TestOutDomainApplicationsTable(t *testing.T) {
out := renderSignedIn(t, "out-domain", outDomainFixture())
pageHas(t, "Domain", out,
`<th>Login</th><th>May send as</th><th>30 days</th><th>Limits</th>`,
`<td class="sp-mono"><strong>prod-server</strong></td>`, `*@<wbr>example.org`,
`alerts@<wbr>example.org, noc@<wbr>example.org`, "1 102 msg · peak 96/h",
`<span class="tag is-success is-light">200 / h</span>`, `<span class="tag is-light">domain</span> <span class="tag is-success is-light">2 IPs</span>`,
`<a href="/outbound/domains/1/applications/2">Edit</a>`,
)
// A state change is never a link, and a table row has no control for one:
// a new password and deleting an application are on the application's page.
pageLacks(t, "Domain", out, `/password`, `/applications/1/delete`)
p := outDomainFixture().WithApplications(nil)
out = renderSignedIn(t, "out-domain", p)
pageHas(t, "Domain", out, `No applications yet.`)
if strings.Contains(out[strings.Index(out, `id="apps"`):], `<table`) {
t.Error("an empty Applications box draws a table")
}
if p.Menu().Groups[0].Items[1].Tag != nil {
t.Error("the menu counts no applications")
}
if tag := outDomainFixture().Menu().Groups[0].Items[1].Tag; tag == nil || tag.Label != "2" {
t.Errorf("the menu's count of applications = %v, want 2", tag)
}
}
func TestOutDomainConnection(t *testing.T) {
out := renderSignedIn(t, "out-domain", outDomainFixture())
pageHas(t, "Domain", out, `<h2>Connection</h2>`, `mail.example.org`, `SSL/TLS (implicit)`, `STARTTLS (submission)`,
`Application login, required on every port`)
out = renderSignedIn(t, "out-domain", outDomainFixture().WithConnection("", false))
pageHas(t, "Domain", out, `SELFPOST_HOSTNAME is not set`)
pageLacks(t, "Domain", out, `STARTTLS`, `Port 587`)
}
// The side menu leads to the boxes, to the pages about the same domain and to
// the settings; the entries the viewer cannot use are not there.
func TestOutDomainMenuFollowsTheViewer(t *testing.T) {
out := renderSignedIn(t, "out-domain", outDomainFixture())
pageHas(t, "Domain", out,
`href="#dns"`, `href="#apps"`, `href="#connection"`, `href="/outbound/log?domain=example.org"`, `href="/outbound/dmarc/domains/1"`,
`href="/outbound/domains/1/settings#reports"`, `href="/outbound/domains/1/settings#limit"`, `href="/outbound/domains/1/settings#export"`,
`href="/outbound/domains/1/delete"`, `id="dns"`, `id="apps"`, `id="connection"`,
`class="sp-help" href="/help#dns"`, `class="sp-help" href="/help#apps"`,
)
p := NewOutDomain(Meta{User: "ops", HasOutbound: true}, 1, "example.org", false).WithRecords(nil, "")
menu := p.Menu()
if len(menu.Groups) != 2 {
t.Fatalf("the menu has %d groups with nothing to see also, want 2", len(menu.Groups))
}
for _, it := range menu.Groups[1].Items {
if it.Danger {
t.Error("a domain administrator is offered Delete domain")
}
}
if it := menu.Groups[1].Items[1]; it.Tag != nil {
t.Errorf("a domain without a limit of its own has the tag %v", it.Tag)
}
if tag := outDomainFixture().WithRateLimit(false, true).Menu().Groups[2].Items[1].Tag; tag == nil || tag.Label != "manual" {
t.Errorf("a manual limit's menu tag = %v", tag)
}
}
func TestOutDomainResultIsTheFlash(t *testing.T) {
out := renderSignedIn(t, "out-domain", outDomainFixture().WithResult("DNS re-checked."))
pageHas(t, "Domain", out, `<div class="notification is-success is-light">`, `DNS re-checked.`)
pageLacks(t, "Domain", renderSignedIn(t, "out-domain", outDomainFixture()), `notification`)
}
// ---- Domain settings
func TestOutDomainSettingsHasAFormPerBox(t *testing.T) {
out := renderSignedIn(t, "out-domain-settings", outDomainSettingsFixture())
pageHas(t, "Settings", out,
`<h1 class="title is-3">Domain settings</h1>`,
`<a href="/outbound/domains">Outbound domains</a> /`, `<a href="/outbound/domains/1">example.org</a> /`,
`id="reports"`, `id="limit"`, `id="export"`,
`action="/outbound/domains/1/settings/reports"`, `name="dmarc_rua_mode"`, `data-custom-mode="custom"`, `data-custom-address`,
`name="dmarc_rua_email"`, `<option value="inherit" selected>admin&#39;s default — mix@example.org</option>`,
`<option value="hosted">`, `<option value="none">`, `<option value="custom">`, `href="/account#dmarc"`,
`action="/outbound/domains/1/settings/export"`, `name="encrypt" value="1" checked data-encrypt-toggle`, `data-encrypt-fields`,
`name="password"`, `name="password_confirm"`, `minlength="12"`, `at least 12 characters`,
`action="/outbound/domains/1/settings/ratelimit"`, `name="mode"`, `data-ratelimit-mode`, `data-manual-fields`, `data-auto-fields`,
`name="max_messages"`, `max="600"`, `name="window_seconds"`, `name="auto_multiplier"`, `min="1.5" max="5"`, `Multiplier (1.5–5, default 2.5)`,
`Computed limit: <strong>288</strong> messages / 3600 s. Peak was 96 msg/h. Last recalculated 2026-10-09 03:00 UTC.`,
`<option value="auto" selected>`, `<span class="tag is-success is-light">active</span>`, `class="sp-help" href="/help#limits"`,
`600 messages / 3600 s per client IP, set in <code>.env</code>`,
`action="/outbound/domains/1/settings/ratelimit/recalc"`, `name="clear" value="1"`, `data-confirm="Remove the domain rate limit?`,
`<h2>Delete domain</h2>`, `Removes the DKIM key and both applications.`, `href="/outbound/domains/1/delete"`, `Delete example.org…`,
)
// A domain's limit is for every sender: it never asks for client IPs.
pageLacks(t, "Settings", out, `name="auth_allowed_ips"`, `auth_ip_restrict`)
// The rate-limit form's Save button is outside the form and points at it.
pageHas(t, "Settings", out, `<form class="sp-form mb-3" id="rate-limit"`, `form="rate-limit">Save limit`)
}
func TestOutDomainSettingsStatesTheFixtureDoesNotDraw(t *testing.T) {
// A manual limit: nothing to recalculate, and the mode says manual.
p := outDomainSettingsFixture().WithRateLimit(DomainRateLimit{
Active: true, MaxMessages: "100", Window: "3600", Multiplier: "2.5",
L1Messages: 600, L1Window: 3600, MinMultiplier: "1.5", MaxMultiplier: "5", DefaultMultiplier: "2.5",
})
out := renderSignedIn(t, "out-domain-settings", p)
pageHas(t, "Settings", out, `<option value="manual" selected>`, `value="100"`, `name="clear"`)
pageLacks(t, "Settings", out, `/ratelimit/recalc`)
// No limit of its own: inactive, nothing to remove, nothing to recalculate.
p = outDomainSettingsFixture().WithRateLimit(DomainRateLimit{
Window: "3600", L1Messages: 600, L1Window: 3600, MinMultiplier: "1.5", MaxMultiplier: "5", DefaultMultiplier: "2.5",
})
out = renderSignedIn(t, "out-domain-settings", p)
pageHas(t, "Settings", out, `<span class="tag is-light">inactive</span>`, `placeholder="600"`)
pageLacks(t, "Settings", out, `name="clear"`, `/ratelimit/recalc`)
// An automatic limit with nothing computed yet says why.
p = outDomainSettingsFixture().WithRateLimit(DomainRateLimit{
Active: false, Auto: true, L1Messages: 600, L1Window: 3600, MinMultiplier: "1.5", MaxMultiplier: "5", DefaultMultiplier: "2.5",
})
pageHas(t, "Settings", renderSignedIn(t, "out-domain-settings", p), `No limit is computed yet: zero traffic keeps auto inactive`)
// A domain administrator cannot delete the domain: no Delete box.
p = NewOutDomainSettings(Meta{User: "ops", HasOutbound: true}, 1, "example.org", 1, false).
WithExport(12).WithRateLimit(DomainRateLimit{L1Messages: 600, L1Window: 3600})
out = renderSignedIn(t, "out-domain-settings", p)
pageHas(t, "Settings", out, `<h2>Rate limit</h2>`, `<h2>Export domain</h2>`)
pageLacks(t, "Settings", out, `Delete domain`, `/delete`)
// A refusal comes back as the flash, with the custom address that was typed.
p = outDomainSettingsFixture().WithReportAddress([]Option{{Value: "custom", Label: "Custom address"}}, "custom", `x"><i>`, nil).
WithResult("", "Enter a valid e-mail address.")
out = renderSignedIn(t, "out-domain-settings", p)
pageHas(t, "Settings", out, `<div class="notification is-danger is-light">`, `Enter a valid e-mail address.`, `<option value="custom" selected>`)
pageLacks(t, "Settings", out, `<i>`)
}
func TestDeleteBoxNamesTheApplicationsItTakes(t *testing.T) {
for n, want := range map[int]string{
0: "Removes the DKIM key.", 1: "Removes the DKIM key and its application.",
2: "Removes the DKIM key and both applications.", 5: "Removes the DKIM key and all 5 applications.",
} {
p := NewOutDomainSettings(admin(), 1, "example.org", n, true)
if got := p.DeleteText[0].Text; got != want+" Asks once more before doing it." {
t.Errorf("%d applications: %q", n, got)
}
}
}
// ---- Application form
func TestOutAppFormIsOneForm(t *testing.T) {
out := renderSignedIn(t, "out-app", outAppFixture())
pageHas(t, "Application", out,
`<h1 class="title is-3 sp-mono">prod-server</h1>`, `<p class="sp-kicker"><a href="/outbound/domains">Outbound domains</a> / <a href="/outbound/domains/1">example.org</a> / Applications /</p>`,
"1 102 messages in 30 days · peak 96 msg/h · average 1.5 msg/h",
// the head's two buttons: both are POSTs, and each asks first
`action="/outbound/domains/1/applications/1/password" data-confirm="Generate a new password for prod-server?`,
`action="/outbound/domains/1/applications/1/delete" data-confirm="Delete application prod-server?`,
// one form, one POST, the fields of all three parts
`<form class="sp-form" method="post" action="/outbound/domains/1/applications/1">`,
`name="login" value="prod-server" readonly`, `name="mode" data-list-mode="list"`, `<option value="wildcard" selected>`,
`data-addresses`, `name="addresses"`, `placeholder="alerts@example.org"`,
`name="auth_ip_restrict" value="1"`, `> Restrict to listed IPs`, `name="auth_allowed_ips"`, `<span class="tag is-light">off</span>`,
`name="rl_mode" data-ratelimit-mode`, `<option value="manual" selected>`, `name="max_messages"`, `value="200"`, `max="600"`,
`name="window_seconds"`, `value="3600"`, `data-auto-fields`, `name="auto_multiplier"`, `Multiplier (1.5–5, default 2.5)`,
`Overrides the domain limit (288 / h) for this application`, `<span class="tag is-success is-light">active</span>`,
`Save application`, `href="/outbound/domains/1">Cancel</a>`,
)
if got := strings.Count(out, `<form class="sp-form"`); got != 1 {
t.Errorf("the page has %d forms of its own, want the one", got)
}
if got := strings.Count(out, `type="submit" class="button is-primary"`); got != 1 {
t.Errorf("%d primary buttons, want the one that saves the whole form", got)
}
pageLacks(t, "Application", out, `/ratelimit/recalc`, `Recalculate`, `checked`)
}
func TestOutAppNewFormAddsAnApplication(t *testing.T) {
p := NewOutApp(admin(), 1, "example.org", "").WithForm(OutAppForm{
Mode: AddressWildcard, LimitMode: LimitDomain, Window: "3600", Multiplier: "2.5",
}, OutAppState{}, AppRateLimit{L1Messages: 600, L1Window: 3600, MinMultiplier: "1.5", MaxMultiplier: "5", DefaultMultiplier: "2.5"})
out := renderSignedIn(t, "out-app", p)
pageHas(t, "New application", out,
`<h1 class="title is-3">New application</h1>`, `action="/outbound/domains/1/applications/new"`, `Create application`,
`name="login" value=""`, `Unique across domains; letters, digits, <code>.</code> <code>-</code> <code>_</code>.`,
`A password is generated when it is created and shown once.`, `<option value="domain" selected>`,
`<span class="tag is-light">inactive</span>`, `placeholder="alerts@example.org"`,
)
pageLacks(t, "New application", out, ` readonly`, `data-confirm`, `/password`)
if p.Head.Actions != nil {
t.Error("the form that adds an application has buttons for one that does not exist")
}
}
// What was posted comes back with the refusal; what the page says is stored
// stays what is stored.
func TestOutAppFormShowsTheRefusalWithWhatWasTyped(t *testing.T) {
typed := OutAppForm{
Login: `x"><b>`, Mode: AddressList, Addresses: "a@example.org\nb@<i>", IPRestrict: true, AllowedIPs: "203.0.113.10\n<script>",
LimitMode: LimitAuto, MaxMessages: "", Window: "3600", Multiplier: "9",
}
p := NewOutApp(admin(), 1, "example.org", "").WithForm(typed, OutAppState{},
AppRateLimit{L1Messages: 600, L1Window: 3600, MinMultiplier: "1.5", MaxMultiplier: "5", DefaultMultiplier: "2.5"}).
WithResult("", "multiplier must be between 1.5 and 5.0")
out := renderSignedIn(t, "out-app", p)
pageHas(t, "New application", out, `<div class="notification is-danger is-light">`, `multiplier must be between 1.5 and 5.0`,
`<option value="list" selected>`, `<option value="auto" selected>`, `auth_ip_restrict" value="1" checked`, `value="9"`)
pageLacks(t, "New application", out, `<b>`, `<i>`, `<script>`)
if strings.Index(out, `class="notification`) > strings.Index(out, `<form class="sp-form"`) {
t.Error("the refusal is not above the form")
}
}
// An application whose limit is automatic can be recalculated; the button is in
// the submit row, after the one that saves, so Enter in a field saves.
func TestOutAppAutoLimitHasRecalculate(t *testing.T) {
p := outAppFixture().WithForm(OutAppForm{Login: "prod-server", Mode: AddressWildcard, LimitMode: LimitAuto, Window: "3600", Multiplier: "2.5"},
OutAppState{Limit: true, Auto: true},
AppRateLimit{L1Messages: 600, L1Window: 3600, MinMultiplier: "1.5", MaxMultiplier: "5", DefaultMultiplier: "2.5",
Computed: "288", Peak: 96, Updated: "2026-10-09 03:00 UTC"})
out := renderSignedIn(t, "out-app", p)
pageHas(t, "Application", out, `formaction="/outbound/domains/1/applications/1/ratelimit/recalc"`, `Recalculate rate limit`,
`Computed limit: <strong>288</strong> messages / 3600 s.`)
if strings.Index(out, `Save application`) > strings.Index(out, `Recalculate rate limit`) {
t.Error("Recalculate comes before Save, so Enter in a field would recalculate")
}
// Chosen but not saved: there is nothing to recalculate yet.
p = outAppFixture().WithForm(OutAppForm{Login: "prod-server", Mode: AddressWildcard, LimitMode: LimitAuto},
OutAppState{}, AppRateLimit{L1Messages: 600})
pageLacks(t, "Application", renderSignedIn(t, "out-app", p), `Recalculate`)
}
func TestOutAppFallsBackToLevelOneWhenTheDomainHasNoLimit(t *testing.T) {
p := outAppFixture().WithForm(OutAppForm{Login: "prod-server"}, OutAppState{}, AppRateLimit{L1Messages: 600})
pageHas(t, "Application", renderSignedIn(t, "out-app", p), `Overrides the domain limit, if it has one, or level 1 for this application`)
}
// ---- The password, shown once
func TestOutAppCreatedShowsLoginAndPasswordOnce(t *testing.T) {
out := renderSignedIn(t, "out-app-created", outAppCreatedFixture())
pageHas(t, "New password", out,
`<h1 class="title is-3">New password for <span class="sp-mono">prod-server</span></h1>`,
`<div class="box sp-credential">`, `<h2>Shown once</h2>`, `Not stored — copy it now`, `ti-eye`,
`<input class="input" readonly value="prod-server">`, `<input class="input" readonly value="kT7v-Qm2x-Lp9d-Ue4s-Hn6b-Rz3w">`,
`The previous password stopped working when this one was generated.`,
`href="/outbound/domains/1">Done — back to example.org</a>`,
`<h2>Connect with</h2>`, `mail.example.org`, `465 SSL/TLS · 587 STARTTLS`, `*@example.org`,
)
if got := strings.Count(out, `data-copy`); got != 2 {
t.Errorf("%d copy buttons, want one for the login and one for the password", got)
}
// nothing on the page changes anything: Done is a link, so the only form is the shell's Sign out
if got := strings.Count(out, `<form`); got != 1 || !strings.Contains(out, `action="/logout"`) {
t.Errorf("%d forms on the password page, want the shell's Sign out alone", got)
}
// An application created just now had no password before.
p := NewOutAppCreated(admin(), 1, "example.org", "alerts", "pw", true, "", false, []string{"alerts@example.org", "noc@example.org"})
out = renderSignedIn(t, "out-app-created", p)
pageHas(t, "New password", out, `If this one is lost, generate another from the application&#39;s page.`,
`<dd class="sp-mono">alerts@example.org, noc@example.org</dd>`, `SELFPOST_HOSTNAME is not set`)
pageLacks(t, "New password", out, `stopped working`, `STARTTLS`)
}
func TestOutAppCreatedEscapes(t *testing.T) {
p := NewOutAppCreated(admin(), 1, "example.org", `<b>x</b>`, `"><script>1</script>`, false, "h", false, []string{`<i>`})
pageLacks(t, "New password", renderSignedIn(t, "out-app-created", p), `<b>x`, `<script>1`, `<i>`)
}
// ---- Delete an outbound domain
func TestOutDomainDeleteNamesWhatGoesWithIt(t *testing.T) {
out := renderSignedIn(t, "out-domain-delete", outDomainDeleteFixture())
pageHas(t, "Delete", out,
`<h1 class="title is-3">Delete example.org</h1>`, `<div class="box sp-danger-zone">`, `<h2>This cannot be undone</h2>`,
`Deleting <strong>example.org</strong> will:`, `permanently delete its DKIM signing key;`,
`delete <strong>both applications</strong> (<span class="sp-mono">prod-server</span>, <span class="sp-mono">alerts</span>) with their credentials and sender bindings;`,
`reload OpenDKIM so the domain is no longer signed.`, `Inbound domains are not touched.`,
`<form method="post" action="/outbound/domains/1/delete">`, `class="button is-danger"`, `Delete example.org</button>`,
`href="/outbound/domains/1">Keep it</a>`,
`<h2>Moving it instead?</h2>`, `<a href="/outbound/domains/1/settings#export">Export the domain</a>`,
)
// No applications: nothing to say about them.
out = renderSignedIn(t, "out-domain-delete", NewOutDomainDelete(admin(), 1, "example.org", nil))
pageLacks(t, "Delete", out, `applications`, `credentials`)
pageHas(t, "Delete", out, `permanently delete its DKIM signing key;`, `reload OpenDKIM`)
out = renderSignedIn(t, "out-domain-delete", NewOutDomainDelete(admin(), 1, "example.org", []string{"only"}))
pageHas(t, "Delete", out, `<strong>its application</strong> (<span class="sp-mono">only</span>)`)
}
// ---- Helpers
func TestFormatAge(t *testing.T) {
now := time.Date(2026, 10, 10, 12, 0, 0, 0, time.UTC)
for want, ago := range map[string]time.Duration{
"just now": 20 * time.Second, "1 min ago": time.Minute, "3 min ago": 3*time.Minute + 59*time.Second,
"2 h ago": 2 * time.Hour, "2026-10-07": 72 * time.Hour,
} {
if got := FormatAge(now.Add(-ago), now); got != want {
t.Errorf("%v ago = %q, want %q", ago, got, want)
}
}
if FormatAge(time.Time{}, now) != "" {
t.Error("an unknown time is reported")
}
}
func TestDNSStatusTag(t *testing.T) {
for _, c := range []struct {
status string
found bool
want Tag
}{
{"ok", true, Tag{"published", "published"}}, {"warn", true, Tag{"warn", "weak"}},
{"error", true, Tag{"mismatch", "mismatch"}}, {"error", false, Tag{"fail", "missing"}},
{"unknown", false, Tag{"unknown", "not checked"}}, {"", false, Tag{"unknown", "not checked"}},
} {
if got := DNSStatusTag(c.status, c.found); got != c.want {
t.Errorf("DNSStatusTag(%q, %v) = %v, want %v", c.status, c.found, got, c.want)
}
}
// What is not fine never reads as a pass in the stamp.
for status, want := range map[string]string{"published": LevelOK, "warn": LevelWarn, "unknown": LevelWarn, "mismatch": LevelFail, "fail": LevelFail} {
if got := tagLevel(status); got != want {
t.Errorf("tagLevel(%q) = %s, want %s", status, got, want)
}
}
}
func TestStatsLead(t *testing.T) {
if got := StatsLead(1284, 96, "1.8", 30)[0].Text; got != "1 284 messages in 30 days · peak 96 msg/h · average 1.8 msg/h" {
t.Errorf("lead = %q", got)
}
if got := StatsLead(1, 1, "0.0", 1)[0].Text; got != "1 message in 1 day · peak 1 msg/h · average 0.0 msg/h" {
t.Errorf("lead = %q", got)
}
}
// An Action that deletes something or ends a working password asks first: the
// question is the form's data-confirm, which panel.js reads, and nothing else
// of the markup changes.
func TestActionConfirmIsOnlyAnAttribute(t *testing.T) {
plain := partial(t, "action", Action{Label: "Delete", Post: "/x", Danger: true})
asked := partial(t, "action", Action{Label: "Delete", Post: "/x", Danger: true, Confirm: `Delete "x"?`})
mustNotContain(t, plain, "data-confirm")
mustContain(t, asked, `<form method="post" action="/x" data-confirm="Delete &#34;x&#34;?">`)
if strings.Replace(asked, ` data-confirm="Delete &#34;x&#34;?"`, "", 1) != plain {
t.Errorf("Confirm changes more than the attribute:\n%s\n%s", plain, asked)
}
}
+3 -2
View File
@@ -136,6 +136,8 @@
if (!form) {
return;
}
// A control can drive more than one block (the rate-limit select shows the
// manual fields or the auto ones), so every rule that matches is applied.
for (var i = 0; i < showWhenRules.length; i++) {
var rule = showWhenRules[i];
if (!control.matches(rule.match)) {
@@ -143,7 +145,7 @@
}
var target = form.querySelector(rule.target);
if (!target) {
return;
continue;
}
var show = rule.visible(control);
target.hidden = !show;
@@ -152,7 +154,6 @@
input.value = "";
});
}
return;
}
}
+1 -1
View File
@@ -34,7 +34,7 @@
{{/* action is a button in a page head or a box foot (input: Action): a link, or
a POST form with one submit button when Post is set — a state change is
never a link. */}}
{{define "action"}}{{if .Post}}<form method="post" action="{{.Post}}"><button type="submit" class="button{{if .Primary}} is-primary{{end}}{{if .Danger}} is-danger is-light{{end}}">{{if .Icon}}<i class="ti {{.Icon}}"></i>{{end}}{{.Label}}</button></form>{{else}}<a class="button{{if .Primary}} is-primary{{end}}{{if .Danger}} is-danger is-light{{end}}" href="{{.Href}}">{{if .Icon}}<i class="ti {{.Icon}}"></i>{{end}}{{.Label}}</a>{{end}}{{end}}
{{define "action"}}{{if .Post}}<form method="post" action="{{.Post}}"{{if .Confirm}} data-confirm="{{.Confirm}}"{{end}}><button type="submit" class="button{{if .Primary}} is-primary{{end}}{{if .Danger}} is-danger is-light{{end}}">{{if .Icon}}<i class="ti {{.Icon}}"></i>{{end}}{{.Label}}</button></form>{{else}}<a class="button{{if .Primary}} is-primary{{end}}{{if .Danger}} is-danger is-light{{end}}" href="{{.Href}}">{{if .Icon}}<i class="ti {{.Icon}}"></i>{{end}}{{.Label}}</a>{{end}}{{end}}
{{/* ---- page head and postmark */}}
@@ -1,48 +0,0 @@
{{define "content"}}
<h1>Domains</h1>
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
{{if .IsGlobal}}
<div class="card">
<h2>Add a sending domain</h2>
<form method="post" action="/outbound/domains">
<label for="name">Domain</label>
<div class="input-row">
<input id="name" name="name" type="text" placeholder="example.com"
autocomplete="off" autocapitalize="none" spellcheck="false"
value="{{.FormName}}" autofocus required>
<button type="submit">Add domain</button>
</div>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
</form>
</div>
{{end}}
<div class="card">
<h2>Domains</h2>
{{if .Domains}}
<table>
<thead>
<tr><th>Domain</th><th>DNS</th><th>Selector</th><th>Apps</th><th></th></tr>
</thead>
<tbody>
{{range .Domains}}
<tr>
<td><a href="/outbound/domains/{{.ID}}">{{.Name}}</a></td>
<td><a class="st st-{{.DNS}}" href="/outbound/domains/{{.ID}}#dns-status">{{.DNS}}</a></td>
<td class="muted">{{.DKIMSelector}}</td>
<td>{{.AppCount}}</td>
{{if $.IsGlobal}}<td class="actions"><a class="danger" href="/outbound/domains/{{.ID}}/delete">Delete</a></td>{{else}}<td></td>{{end}}
</tr>
{{end}}
</tbody>
</table>
<p class="muted">The <em>DNS</em> badge is the worst of the domain's DKIM, SPF
and DMARC checks. Results are cached for a few minutes; open a domain for the
details and a <em>Re-check</em> button.</p>
{{else}}
<p class="muted">No domains yet. Add one above to get started.</p>
{{end}}
</div>
{{end}}
@@ -1,24 +0,0 @@
{{define "content"}}
<h1>Delete {{.Domain.Name}}</h1>
{{template "back_link" (back (printf "/outbound/domains/%d" .Domain.ID) (printf "Back to %s" .Domain.Name))}}
<div class="card">
<h2>Confirm deletion</h2>
<p>You are about to delete <strong>{{.Domain.Name}}</strong>. This will:</p>
<ul>
<li>permanently delete its DKIM signing key;</li>
<li>delete
{{if eq .Domain.AppCount 0}}its (currently none) bound applications{{else}}
<strong>all {{.Domain.AppCount}} bound application(s)</strong>, including
their SASL credentials and sender bindings{{end}};</li>
<li>reload OpenDKIM so the domain is no longer signed.</li>
</ul>
<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>
<form method="post" action="/outbound/domains/{{.Domain.ID}}/delete">
<button type="submit" class="danger">Delete {{.Domain.Name}}</button>
</form>
</div>
{{end}}
@@ -1,513 +0,0 @@
{{/* Wide so the .split pairs fill the column rather than the 48rem reading
measure (same pattern as Status). */}}
{{define "wide"}}wide{{end}}
{{/* The Host/name ‖ Type field-pair repeats for every DNS record this page
shows (DKIM, SPF, DMARC, report authorization) in both the status card
and the publishable-record cards below it — only the host and whether it
carries a Copy button change. Two variants rather than one templated
Copy flag: the DNS status card never offers Copy (its host is derived,
not something to paste), the record cards always do. */}}
{{define "host_type"}}
<div class="field-pair host-type">
<div>
<label>Host / name</label>
<span class="code">{{.}}</span>
</div>
<div class="field-type">
<label>Type</label>
<span class="code">TXT</span>
</div>
</div>
{{end}}
{{define "host_type_copy"}}
<div class="field-pair host-type">
<div>
<label>Host / name</label>
<div class="code-row">
<span class="code">{{.}}</span>
<button type="button" class="copy">Copy</button>
</div>
</div>
<div class="field-type">
<label>Type</label>
<span class="code">TXT</span>
</div>
</div>
{{end}}
{{/* Value partials pair with the two host_type variants above: field_values
is the DNS status card's raw record dump (no Copy — those values are for
comparison, not for pasting), field_value is the single publishable
value on the record cards below (always has Copy). Callers still guard
the empty case, since "no records yet" and "one blank record" read
differently. */}}
{{define "field_values"}}
<label>Value</label>
<span class="code">{{range .}}{{.}}
{{end}}</span>
{{end}}
{{define "field_value"}}
<label>Value</label>
<div class="code-row">
<span class="code">{{.}}</span>
<button type="button" class="copy">Copy</button>
</div>
{{end}}
{{define "content"}}
<h1>{{.Domain.Name}}</h1>
{{template "back_link" (back "/outbound/domains" "All domains")}}
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
{{if .RateLimitErr}}<div class="flash error">{{.RateLimitErr}}</div>{{end}}
{{if .NewCred}}
<div class="card credential" id="new-credential">
<div class="card-head">
<h2>New application password</h2>
{{template "card_help" (dict "ID" "password" "Title" "Why this is shown once")}}
</div>
<p class="muted">Shown <strong>once only</strong> and not stored. Copy it now
— if it is lost, regenerate a new one.</p>
<label>Login</label>
<div class="code-row">
<span class="code">{{.NewCred.Login}}</span>
<button type="button" class="copy">Copy</button>
</div>
<label>Password</label>
<div class="code-row">
<span class="code">{{.NewCred.Password}}</span>
<button type="button" class="copy">Copy</button>
</div>
</div>
{{end}}
{{/* Two rows of two checks (.check-cols). */}}
<div class="card" id="dns-status">
<div class="card-head">
<h2>DNS status <span class="st st-{{.DNS.Overall}}">{{.DNS.Overall}}</span></h2>
{{template "card_help" (dict "ID" "dns" "Title" "What these checks mean")}}
</div>
<p class="muted">Cached a few minutes — use <em>Re-check</em> after
publishing.</p>
<div class="check-cols">
<div class="check-col">
<label>DKIM <span class="st st-{{.DNS.DKIM.Status}}">{{.DNS.DKIM.Status}}</span></label>
{{template "host_type" .Record.Name}}
{{if .DNS.DKIM.Records}}{{template "field_values" .DNS.DKIM.Records}}{{end}}
{{if ne .DNS.DKIM.Status "ok"}}
<p class="{{if eq .DNS.DKIM.Status "unknown"}}muted{{else}}error{{end}}">{{.DNS.DKIM.Detail}}</p>
{{end}}
</div>
<div class="check-col">
<label>SPF <span class="st st-{{.DNS.SPF.Status}}">{{.DNS.SPF.Status}}</span></label>
{{template "host_type" .Domain.Name}}
{{if .DNS.SPF.Records}}{{template "field_values" .DNS.SPF.Records}}{{end}}
{{if ne .DNS.SPF.Status "ok"}}
<p class="{{if eq .DNS.SPF.Status "unknown"}}muted{{else}}error{{end}}">{{.DNS.SPF.Detail}}</p>
{{end}}
<p class="muted">Shallow check: literal address only, no <code>include:</code> /
<code>redirect=</code>.</p>
</div>
<div class="check-col">
<label>DMARC <span class="st st-{{.DNS.DMARC.Status}}">{{.DNS.DMARC.Status}}</span></label>
{{template "host_type" .DMARCName}}
{{if .DNS.DMARC.Records}}{{template "field_values" .DNS.DMARC.Records}}{{end}}
{{if ne .DNS.DMARC.Status "ok"}}
<p class="{{if eq .DNS.DMARC.Status "unknown"}}muted{{else}}error{{end}}">{{.DNS.DMARC.Detail}}</p>
{{else}}
<p class="muted">{{.DNS.DMARC.Detail}}</p>
{{end}}
</div>
<div class="check-col">
{{if .DNS.DMARCReportAuth.Status}}
<label>Report authorization <span class="st st-{{.DNS.DMARCReportAuth.Status}}">{{.DNS.DMARCReportAuth.Status}}</span></label>
{{template "host_type" .ReportAuthName}}
{{if .DNS.DMARCReportAuth.Records}}{{template "field_values" .DNS.DMARCReportAuth.Records}}{{end}}
<p class="{{if eq .DNS.DMARCReportAuth.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DMARCReportAuth.Detail}}</p>
{{else}}
<label>Report authorization</label>
<p class="muted">Not required (no external <code>rua=</code>).</p>
{{end}}
</div>
</div>
<form class="inline" method="post" action="/outbound/domains/{{.Domain.ID}}/dns-recheck">
<button type="submit">Re-check</button>
</form>
</div>
<div class="split">
<div class="card" id="dkim-spf">
<div class="card-head">
<h2>DKIM and SPF records</h2>
{{template "card_help" (dict "ID" "records" "Title" "What to publish")}}
</div>
<p class="check-col-title">DKIM</p>
{{template "host_type_copy" .Record.Name}}
{{template "field_value" .Record.Value}}
<p class="muted">Not a secret. Signed with selector
<strong>{{.Domain.DKIMSelector}}</strong>.</p>
<p class="check-col-title">SPF</p>
{{template "host_type_copy" .Domain.Name}}
{{template "field_value" .SPFExample}}
<p class="muted">Merge into an existing SPF if the domain already has one —
do not publish a second record.</p>
</div>
<div class="card" id="dmarc">
<div class="card-head">
<h2>DMARC record</h2>
{{template "card_help" (dict "ID" "dmarc" "Title" "What this record does")}}
</div>
{{template "host_type_copy" .DMARCName}}
<label>Value{{if eq .DMARCSource "settings"}} <span class="muted">(from Settings)</span>{{else if eq .DMARCSource "custom"}} <span class="muted">(custom)</span>{{else if eq .DMARCSource "none"}} <span class="muted">(no reports)</span>{{end}}</label>
<div class="code-row">
<span class="code">{{.DMARCExample}}</span>
<button type="button" class="copy">Copy</button>
</div>
{{if .SameDomainRUA}}
<p class="error">The report address is on this sending domain.{{if not .DMARCIngestEnabled}}
SelfPost does not receive inbound mail — use a mailbox on another domain.{{end}}</p>
{{end}}
{{if and .DMARCIngestEnabled .ResolvedDMARCEmail}}
<p class="muted"><a href="/outbound/dmarc/domains/{{.Domain.ID}}">View DMARC reports</a> for this domain.</p>
{{end}}
{{if .NeedsReportAuth}}
<p class="check-col-title">Report authorization</p>
{{template "host_type_copy" .ReportAuthName}}
{{template "field_value" .ReportAuthValue}}
{{end}}
<p class="muted"><code>p=none</code> does not affect delivery. Tighten to
<code>p=quarantine</code> then <code>p=reject</code> once reports look clean.
Report address is set under <a href="#domain-settings">Domain settings</a>.</p>
</div>
</div>
<div class="split">
<div class="card" id="connection">
<div class="card-head">
<h2>Connection settings</h2>
{{template "card_help" (dict "ID" "connection" "Title" "How to connect")}}
</div>
<p class="muted">Same for every domain. Authenticate with an application
login from below.</p>
<label>Server</label>
<div class="code-row">
<span class="code">{{.Hostname}}</span>
<button type="button" class="copy">Copy</button>
</div>
<label>Port and encryption</label>
<span class="code">465 — SSL/TLS (implicit){{if .SubmissionEnabled}}
587 — STARTTLS (submission){{end}}</span>
<p class="muted">Auth required on every port. The password is shown once at
create or regenerate.</p>
</div>
{{/* Create form beside connection settings, mirroring "Add a sending domain"
above the domains list. */}}
<div class="card" id="add-application">
<h2>Add an application</h2>
<form method="post" action="/outbound/domains/{{.Domain.ID}}/applications/new">
<label for="login">Login</label>
<input id="login" name="login" type="text" placeholder="prod-server"
autocomplete="off" autocapitalize="none" spellcheck="false"
value="{{.FormLogin}}" required>
<label for="mode">Address mode</label>
<select id="mode" name="mode" data-list-mode="{{.List}}">
<option value="{{.Wildcard}}" {{if eq .FormMode .Wildcard}}selected{{end}}>Any address of the domain</option>
<option value="{{.List}}" {{if eq .FormMode .List}}selected{{end}}>Specific addresses (list)</option>
</select>
<div data-addresses>
<label for="addresses">Addresses (one per line or comma-separated)</label>
<textarea id="addresses" name="addresses" rows="3"
placeholder="alerts@{{.Domain.Name}}">{{.FormAddrs}}</textarea>
</div>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<button type="submit">Create application</button>
</form>
<p class="muted">Password shown once. Login unique across domains; letters,
digits, '.', '-' and '_'.</p>
</div>
</div>
<div class="card" id="send-stats">
<h2>Sending statistics ({{.StatsWindowDays}} days)</h2>
<p class="muted">One message = one queue id (many recipients count once).
Level-1 refusals are not in the send log — totals under-count strict IP
limits.{{if .StatsRetentionWarning}} Send log retention is shorter than
30&nbsp;days; statistics use the last {{.StatsWindowDays}}&nbsp;days only.{{end}}</p>
<div class="field-pair">
<div>
<label>Total messages</label>
<span>{{.DomainStats.Total}}</span>
</div>
<div>
<label>Peak rate (msg/h)</label>
<span>{{.DomainStats.PeakPerHour}}</span>
</div>
<div>
<label>Average rate (msg/h)</label>
<span>{{.DomainStats.AvgPerHour}}</span>
</div>
</div>
</div>
<div class="card" id="applications">
<div class="card-head">
<h2>Applications</h2>
{{template "card_help" (dict "ID" "apps" "Title" "What an application is")}}
</div>
<p class="muted">SASL logins for this domain — wildcard (*@domain) or a fixed
address list.</p>
{{if .Apps}}
<ul class="apps">
{{range .Apps}}
<li class="app">
<p class="app-login">{{.Login}}</p>
<p class="app-addr muted">
{{if eq .AddressMode $.Wildcard}}Any address of the domain — *@{{$.Domain.Name}}
{{else}}Fixed list — {{range $i, $a := .Addresses}}{{if $i}}, {{end}}{{$a}}{{end}}{{end}}
</p>
<p class="muted app-stats">{{.Stats.Total}} msg / {{$.StatsWindowDays}}d ·
peak {{.Stats.PeakPerHour}} msg/h · avg {{.Stats.AvgPerHour}} msg/h</p>
<!-- One Edit panel (mode ‖ rate limit) opened by a checkbox and label
rather than <details>, so the button row stays intact — see
.panel-toggle in panel.css. -->
<div class="actions">
<input class="panel-toggle t-edit" id="edit-{{.ID}}" type="checkbox">
<label class="toggle for-edit" for="edit-{{.ID}}">Edit{{if .HasLimit}} <span class="st st-ok">limit</span>{{end}}</label>
<form class="inline" method="post" action="/outbound/domains/{{$.Domain.ID}}/applications/{{.ID}}/password"
data-confirm="Regenerate the password for {{.Login}}? The current password stops working immediately.">
<button type="submit">New password</button>
</form>
<form class="inline" method="post" action="/outbound/domains/{{$.Domain.ID}}/applications/{{.ID}}/delete"
data-confirm="Delete application {{.Login}}? Its credentials stop working immediately.">
<button type="submit" class="danger">Delete</button>
</form>
<div class="panel panel-edit">
{{/* The application form is one POST: sender, client IPs and rate
limit are saved together by the one button under the columns. */}}
<form id="app-{{.ID}}" method="post" action="/outbound/domains/{{$.Domain.ID}}/applications/{{.ID}}">
<div class="check-cols">
<div class="check-col">
<p class="check-col-title">Address mode</p>
<p class="muted">Which From addresses this application may use.</p>
<select name="mode" data-list-mode="{{$.List}}" aria-label="Address mode">
<option value="{{$.Wildcard}}" {{if eq .AddressMode $.Wildcard}}selected{{end}}>Any address of the domain</option>
<option value="{{$.List}}" {{if eq .AddressMode $.List}}selected{{end}}>Specific addresses (list)</option>
</select>
<div data-addresses>
<label>Addresses (one per line or comma-separated)</label>
<textarea name="addresses" rows="3" placeholder="alerts@{{$.Domain.Name}}">{{range $i, $a := .Addresses}}{{if $i}}
{{end}}{{$a}}{{end}}</textarea>
</div>
</div>
<div class="check-col">
<p class="check-col-title">Client IP allow-list
{{if .AuthIPRestrict}}<span class="st st-ok">active</span>{{else}}<span class="st st-unknown">off</span>{{end}}</p>
<p class="muted">When enabled, only these addresses may submit mail as
this application; a login from anywhere else is refused with a 4xx at
<code>MAIL FROM</code>. When off, any client IP is allowed. Like the
level-2 rate limit this runs in the journal-milter and is fail-open —
it is a sending control, not an authentication boundary.</p>
<label class="check-row">
<input type="checkbox" name="auth_ip_restrict" value="1"
{{if .AuthIPRestrict}}checked{{end}}>
Restrict to listed IPs
</label>
<textarea name="auth_allowed_ips" rows="2" placeholder="203.0.113.10"
aria-label="Allowed client IPs">{{.AuthIPsText}}</textarea>
</div>
<div class="check-col">
<p class="check-col-title">Level-2 rate limit
{{if .HasLimit}}<span class="st st-ok">active</span>{{else}}<span class="st st-unknown">inactive</span>{{end}}</p>
<p class="muted">Overrides the domain limit for this application — the
ceiling may be <em>higher or lower</em> than the domain setting
(≤ level&nbsp;1). When unset, the domain limit{{if $.DomainHasRL}}
({{$.DomainRLMaxNum}}){{end}} or level&nbsp;1 applies.</p>
<label for="mode-{{.ID}}">Limit mode</label>
<select id="mode-{{.ID}}" name="rl_mode" data-ratelimit-mode>
<option value="domain" {{if eq .RLMode "domain"}}selected{{end}}>Use the domain limit</option>
<option value="manual" {{if eq .RLMode "manual"}}selected{{end}}>Manual</option>
<option value="auto" {{if eq .RLMode "auto"}}selected{{end}}>Auto (from statistics)</option>
</select>
<div data-manual-fields>
<div class="field-pair">
<div>
<label>Message limit (max {{$.L1Messages}})</label>
<input name="max_messages" type="number" min="1" max="{{$.L1Messages}}"
value="{{.MaxText}}" placeholder="{{$.L1Messages}}">
</div>
<div>
<label>Window (seconds)</label>
<input name="window_seconds" type="number" min="1" value="{{.WindowVal}}">
</div>
</div>
</div>
<div data-auto-fields>
<label for="mult-{{.ID}}">Multiplier ({{$.MinAutoMultiplier}}–{{$.MaxAutoMultiplier}}, default {{$.DefaultAutoMultiplier}})</label>
<input id="mult-{{.ID}}" name="auto_multiplier" type="number" min="{{$.MinAutoMultiplier}}"
max="{{$.MaxAutoMultiplier}}" step="0.1" value="{{.AutoMultiplier}}">
<p class="muted">Computed limit: <strong>{{.MaxText}}</strong> messages /
{{$.L1Window}}&nbsp;s (level-1 window). Zero traffic keeps auto inactive until
messages are sent.{{if .AutoUpdated}} Last recalculated {{.AutoUpdated}}.{{end}}
Peak {{.Stats.PeakPerHour}} msg/h shown for comparison.</p>
</div>
</div>
</div>
</form>
<div class="panel-buttons">
<button type="submit" form="app-{{.ID}}">Save application</button>
{{if .IsAuto}}
<form class="inline" method="post" action="/outbound/domains/{{$.Domain.ID}}/applications/{{.ID}}/ratelimit/recalc">
<button type="submit">Recalculate now</button>
</form>
{{end}}
</div>
</div>
</div>
</li>
{{end}}
</ul>
{{else}}
<p class="muted">No applications yet. Add one above to get started.</p>
{{end}}
</div>
<div class="card" id="domain-settings">
<div class="card-head">
<h2>Domain settings</h2>
{{template "card_help" (dict "ID" "domain-settings" "Title" "Reports and rate limits")}}
</div>
<div class="check-cols check-cols-rows">
<div class="check-col">
<p class="check-col-title">DMARC reports</p>
<p class="muted">Default comes from <a href="/account#dmarc">Account</a>;
override per domain here.</p>
<div class="check-col-fields">
<form id="dmarc-domain" method="post" action="/outbound/domains/{{.Domain.ID}}/settings/reports">
<label for="dmarc_rua_mode">Aggregate reports (rua=)</label>
<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>
{{if .DMARCIngestEnabled}}<option value="hosted"{{if eq .DMARCRuaMode "hosted"}} selected{{end}}>SelfPost hosted ({{.HostedDMARCEmail}})</option>{{end}}
<option value="none"{{if eq .DMARCRuaMode "none"}} selected{{end}}>No aggregate reports</option>
<option value="custom"{{if eq .DMARCRuaMode "custom"}} selected{{end}}>Custom address</option>
</select>
<div data-custom-address>
<label for="dmarc_rua_email">Custom report address</label>
<input id="dmarc_rua_email" name="dmarc_rua_email" type="email"
autocapitalize="none" spellcheck="false" value="{{.DMARCRuaCustom}}"
placeholder="reports@your-mail-domain.com">
</div>
</form>
</div>
<div class="check-col-actions">
<button type="submit" form="dmarc-domain">Save DMARC report settings</button>
</div>
</div>
<div class="check-col">
<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>
<p class="muted">Level&nbsp;1 backstop: {{.L1Messages}} messages / {{.L1Window}}s {{if .IsGlobal}} — <a href="/server/settings#rate-limits">Settings</a>{{end}}.</p>
<div class="check-col-fields">
<form id="rl-domain" method="post" action="/outbound/domains/{{.Domain.ID}}/settings/ratelimit">
<label for="d_mode">Limit mode</label>
<select id="d_mode" name="mode" data-ratelimit-mode>
<option value="manual" {{if not .DomainRLAuto}}selected{{end}}>Manual</option>
<option value="auto" {{if .DomainRLAuto}}selected{{end}}>Auto (from statistics)</option>
</select>
<div data-manual-fields>
<div class="field-pair">
<div>
<label for="d_max">Message limit (max {{.L1Messages}})</label>
<input id="d_max" name="max_messages" type="number" min="1" max="{{.L1Messages}}"
value="{{.DomainRLMax}}" placeholder="{{.L1Messages}}">
</div>
<div>
<label for="d_win">Window (seconds)</label>
<input id="d_win" name="window_seconds" type="number" min="1" value="{{.DomainRLWin}}">
</div>
</div>
<p class="muted">Applies to every client IP on this domain. Leave the
message limit empty to use level&nbsp;1 only.</p>
</div>
<div data-auto-fields>
<label for="d_mult">Multiplier ({{.MinAutoMultiplier}}–{{.MaxAutoMultiplier}}, default {{.DefaultAutoMultiplier}})</label>
<input id="d_mult" name="auto_multiplier" type="number" min="{{.MinAutoMultiplier}}"
max="{{.MaxAutoMultiplier}}" step="0.1" value="{{.DomainRLMultiplier}}">
<p class="muted">Computed limit: <strong>{{.DomainRLMax}}</strong> messages /
{{.L1Window}}&nbsp;s (level-1 window). Zero traffic keeps auto inactive until
messages are sent.{{if .DomainRLAutoUpdated}} Last recalculated {{.DomainRLAutoUpdated}}.{{end}}
Peak {{.DomainStats.PeakPerHour}} msg/h shown for comparison.</p>
</div>
</form>
</div>
<div class="check-col-actions">
<button type="submit" form="rl-domain">Save limit</button>
{{if .DomainRLAuto}}
<form class="inline" method="post" action="/outbound/domains/{{.Domain.ID}}/settings/ratelimit/recalc">
<button type="submit">Recalculate now</button>
</form>
{{end}}
{{if .DomainHasRL}}
<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.">
<input type="hidden" name="clear" value="1">
<button type="submit" class="danger">Remove limit</button>
</form>
{{end}}
</div>
</div>
</div>
</div>
<div class="split">
<div class="card" id="export">
<div class="card-head">
<h2>Export domain</h2>
{{template "card_help" (dict "ID" "export" "Title" "What the file contains")}}
</div>
<p class="muted"><strong>Secret file</strong> — transfer securely, or encrypt
below as <code>.spde</code>.</p>
{{if .ExportErr}}<p class="error">{{.ExportErr}}</p>{{end}}
<form method="post" action="/outbound/domains/{{.Domain.ID}}/settings/export">
{{template "encryptfields" .}}
<button type="submit">Export domain</button>
</form>
</div>
<div class="card" id="danger">
<h2>Danger zone</h2>
<p class="muted">Deletes the DKIM key and every application on this domain.</p>
<a class="danger" href="/outbound/domains/{{.Domain.ID}}/delete">Delete domain</a>
</div>
</div>
{{end}}
+71
View File
@@ -0,0 +1,71 @@
{{/* The application form (mockup out-app.html): who the login may send as, which
client IPs may use it and its own rate limit, in one form with one button.
The same form, empty, adds an application; the three parts are checked and
saved together or not at all. */}}
{{define "content"}}
{{template "page_head" .Head}}
{{if .Flash}}{{template "flash" .Flash}}{{end}}
<form class="sp-form" method="post" action="{{.Action}}">
<div class="columns">
<div class="column is-4">
{{template "box_open" .Sender}}
{{template "body_open"}}
<div class="field"><label class="label" for="login">Login</label>
<div class="control"><input class="input sp-mono" id="login" name="login" value="{{.Login}}"{{if .LoginFixed}} readonly{{end}} autocomplete="off" autocapitalize="none" spellcheck="false" required></div>
<p class="help">{{template "inline" .LoginHelp}}</p></div>
<div class="field"><label class="label" for="amode">May send as</label>
<div class="control"><div class="select is-fullwidth"><select id="amode" name="mode" data-list-mode="list">{{$mode := .Mode}}{{range .Modes}}
<option value="{{.Value}}"{{if eq .Value $mode}} selected{{end}}>{{.Label}}</option>{{end}}
</select></div></div></div>
<div data-addresses>
<div class="field"><label class="label" for="addresses">Addresses</label>
<div class="control"><textarea class="textarea" id="addresses" name="addresses" rows="3" placeholder="{{.Placeholder}}">{{.Addresses}}</textarea></div>
<p class="help">One per line. Used only with a list.</p></div>
</div>
{{template "body_close"}}
{{template "box_close"}}
</div>
<div class="column is-4">
{{template "box_open" .IPs}}
{{template "body_open"}}
<div class="field"><label class="checkbox"><input type="checkbox" name="auth_ip_restrict" value="1"{{if .IPRestrict}} checked{{end}}> Restrict to listed IPs</label></div>
<div class="field"><label class="label" for="ips">Allowed client IPs</label>
<div class="control"><textarea class="textarea sp-mono" id="ips" name="auth_allowed_ips" rows="3" placeholder="203.0.113.10">{{.AllowedIPs}}</textarea></div>
<p class="help">A login from anywhere else is refused with a 4xx at <code>MAIL FROM</code>. A sending control, fail-open — not an authentication boundary.</p></div>
{{template "body_close"}}
{{template "box_close"}}
</div>
<div class="column is-4">
{{template "box_open" .Limit}}
{{template "body_open"}}
<div class="field"><label class="label" for="rmode">Limit mode</label>
<div class="control"><div class="select is-fullwidth"><select id="rmode" name="rl_mode" data-ratelimit-mode>{{$limit := .LimitMode}}{{range .LimitModes}}
<option value="{{.Value}}"{{if eq .Value $limit}} selected{{end}}>{{.Label}}</option>{{end}}
</select></div></div></div>
<div data-manual-fields>
<div class="sp-pair">
<div class="field"><label class="label" for="max">Messages (max {{.Rate.L1Messages}})</label><div class="control"><input class="input" id="max" name="max_messages" type="number" min="1" max="{{.Rate.L1Messages}}" value="{{.MaxMessages}}" placeholder="{{.Rate.L1Messages}}"></div></div>
<div class="field"><label class="label" for="win">Window, s</label><div class="control"><input class="input" id="win" name="window_seconds" type="number" min="1" value="{{.Window}}"></div></div>
</div>
</div>
<div data-auto-fields>
<div class="field"><label class="label" for="mult">Multiplier ({{.Rate.MinMultiplier}}–{{.Rate.MaxMultiplier}}, default {{.Rate.DefaultMultiplier}})</label>
<div class="control"><input class="input" id="mult" name="auto_multiplier" type="number" min="{{.Rate.MinMultiplier}}" max="{{.Rate.MaxMultiplier}}" step="0.1" value="{{.Multiplier}}"></div>
<p class="help">{{template "inline" .Rate.AutoHelp}}</p></div>
</div>
<p class="help">{{template "inline" .LimitHelp}}</p>
{{template "body_close"}}
{{template "box_close"}}
</div>
</div>
<div class="buttons">
<button type="submit" class="button is-primary">{{.Submit}}</button>
{{if .Recalc}}<button type="submit" class="button" formaction="{{.Recalc}}">Recalculate rate limit</button>{{end}}
<a class="button is-text" href="{{.Cancel}}">Cancel</a>
</div>
</form>
{{end}}
@@ -0,0 +1,26 @@
{{/* The password of an application, shown once (mockup out-app-created.html): the
response to the POST that created the application or issued a new password,
never stored and never cached. It has no address of its own. */}}
{{define "content"}}
{{template "page_head" .Head}}
<div class="columns">
<div class="column is-7">
{{template "box_open" .Credential}}
{{template "record_open"}}
{{template "copy_field" .Login}}
{{template "copy_field" .Password}}
{{template "record_close"}}
{{template "box_foot" .Done}}
{{template "box_close"}}
</div>
<div class="column is-5">
{{template "box_open" .Connect}}
{{template "body_open"}}
{{template "facts" .Facts}}
{{template "body_close"}}
{{template "box_close"}}
</div>
</div>
{{end}}
@@ -0,0 +1,41 @@
{{/* One outbound domain (mockup out-domain.html): the records to publish and
what DNS says about them, the applications that send as the domain, and how
to connect. The side menu leads to the three boxes, to the pages about the
same domain and to its settings. */}}
{{define "content"}}
{{template "page_head" .Head}}
{{if .Flash}}{{template "flash" .Flash}}{{end}}
<div class="columns">
<div class="column is-3">
{{template "side_menu" .Menu}}
</div>
<div class="column is-9">
{{template "box_open" .DNS}}
{{range .Records}}{{template "dns_record" .}}
{{end}}
{{template "box_close"}}
{{template "box_open" .Apps}}
{{if .AppRows}}
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Login</th><th>May send as</th><th>{{.Days}} days</th><th>Limits</th><th></th></tr></thead>
<tbody>{{range .AppRows}}
<tr><td class="sp-mono"><strong>{{.Login}}</strong></td><td>{{range $i, $a := .Senders}}{{if $i}}, {{end}}{{wbr_at $a}}{{end}}</td><td class="sp-muted">{{.Activity}}</td><td>{{range $i, $t := .Limits}}{{if $i}} {{end}}{{template "tag" $t}}{{end}}</td>
<td class="sp-actions"><a href="{{.Edit}}">Edit</a></td></tr>{{end}}
</tbody>
</table></div>
{{else}}
{{template "empty_state" .AppsEmpty}}
{{end}}
{{template "box_close"}}
{{template "box_open" .Connection}}
{{template "body_open"}}
{{template "facts" .ConnFacts}}
{{template "body_close"}}
{{template "box_close"}}
</div>
</div>
{{end}}
@@ -0,0 +1,32 @@
{{/* Delete an outbound domain (mockup out-domain-delete.html): what goes with it,
the one button that does it, and the way back. Moving the domain is the
alternative, beside it. */}}
{{define "content"}}
{{template "page_head" .Head}}
<div class="columns">
<div class="column is-7">
{{template "box_open" .Danger}}
{{template "body_open"}}
<p class="mb-3">{{template "inline" .Intro}}</p>
{{template "confirm_list" .Consequences}}
<p class="sp-muted mb-4">{{template "inline" .Note}}</p>
<form method="post" action="{{.Action}}">
<div class="buttons">
<button type="submit" class="button is-danger"><i class="ti ti-trash"></i>{{.Confirm}}</button>
<a class="button is-text" href="{{.KeepHref}}">Keep it</a>
</div>
</form>
{{template "body_close"}}
{{template "box_close"}}
</div>
<div class="column is-5">
{{template "box_open" .Moving}}
{{template "body_open"}}
<p>{{template "inline" .MovingText}}</p>
{{template "body_close"}}
{{template "box_close"}}
</div>
</div>
{{end}}
@@ -0,0 +1,84 @@
{{/* Domain settings (mockup out-domain-settings.html): the things set once and
rarely touched. Each box is its own form with its own POST and none of them
asks for the others' fields; a refusal comes back as the flash above them.
The delete entry is the global role's. */}}
{{define "content"}}
{{template "page_head" .Head}}
{{if .Flash}}{{template "flash" .Flash}}{{end}}
<div class="columns">
<div class="column is-6">
{{template "box_open" .Reports}}
{{template "body_open"}}
<form class="sp-form" method="post" action="{{.ReportsAction}}">
<div class="field"><label class="label" for="rua">Aggregate reports (rua=)</label>
<div class="control"><div class="select is-fullwidth"><select id="rua" name="dmarc_rua_mode" data-custom-mode="custom">{{$sel := .RuaSelected}}{{range .RuaOptions}}
<option value="{{.Value}}"{{if eq .Value $sel}} selected{{end}}>{{.Label}}</option>{{end}}
</select></div></div>
<p class="help">{{template "inline" .RuaHelp}}</p></div>
<div class="field" data-custom-address><label class="label" for="rua_email">Custom report address</label>
<div class="control"><input class="input" id="rua_email" name="dmarc_rua_email" type="email" value="{{.RuaCustom}}" placeholder="reports@your-mail-domain.com" autocomplete="off" autocapitalize="none" spellcheck="false"></div></div>
<button type="submit" class="button is-primary">Save report address</button>
</form>
{{template "body_close"}}
{{template "box_close"}}
{{template "box_open" .Export}}
{{template "body_open"}}
<p class="mb-3">One file with the DKIM key and every application's password hash — import it on another SelfPost and the published DNS needs no change. <strong>The file is a secret.</strong></p>
<form class="sp-form" method="post" action="{{.ExportAction}}">
<div class="field"><label class="checkbox"><input type="checkbox" name="encrypt" value="1"{{if .Encrypt}} checked{{end}} data-encrypt-toggle> Encrypt with a password (<code>.spde</code>)</label></div>
<div data-encrypt-fields>
<div class="sp-pair">
<div class="field"><label class="label" for="encpw">Password</label><div class="control"><input class="input" id="encpw" name="password" type="password" autocomplete="new-password" minlength="{{.PasswordMin}}" placeholder="at least {{.PasswordMin}} characters"></div></div>
<div class="field"><label class="label" for="encpw2">Repeat password</label><div class="control"><input class="input" id="encpw2" name="password_confirm" type="password" autocomplete="new-password"></div></div>
</div>
<p class="help mb-3">Keep this password: SelfPost does not store it, and the file cannot be opened without it.</p>
</div>
<button type="submit" class="button"><i class="ti ti-download"></i>Export domain</button>
</form>
{{template "body_close"}}
{{template "box_close"}}
</div>
<div class="column is-6">
{{template "box_open" .Limit}}
{{template "body_open"}}
<form class="sp-form mb-3" id="rate-limit" method="post" action="{{.LimitAction}}">
<div class="field"><label class="label" for="mode">Limit mode</label>
<div class="control"><div class="select is-fullwidth"><select id="mode" name="mode" data-ratelimit-mode>
<option value="manual"{{if not .Rate.Auto}} selected{{end}}>Manual</option>
<option value="auto"{{if .Rate.Auto}} selected{{end}}>Auto (from statistics)</option>
</select></div></div></div>
<div data-manual-fields>
<div class="sp-pair">
<div class="field"><label class="label" for="max">Messages (max {{.Rate.L1Messages}})</label><div class="control"><input class="input" id="max" name="max_messages" type="number" min="1" max="{{.Rate.L1Messages}}" value="{{.Rate.MaxMessages}}" placeholder="{{.Rate.L1Messages}}"></div></div>
<div class="field"><label class="label" for="win">Window, s</label><div class="control"><input class="input" id="win" name="window_seconds" type="number" min="1" value="{{.Rate.Window}}"></div></div>
</div>
<p class="help mb-3">Leave the message limit empty to use level 1 only.</p>
</div>
<div data-auto-fields>
<div class="field"><label class="label" for="mult">Multiplier ({{.Rate.MinMultiplier}}–{{.Rate.MaxMultiplier}}, default {{.Rate.DefaultMultiplier}})</label>
<div class="control"><input class="input" id="mult" name="auto_multiplier" type="number" min="{{.Rate.MinMultiplier}}" max="{{.Rate.MaxMultiplier}}" step="0.1" value="{{.Rate.Multiplier}}"></div>
<p class="help">{{template "inline" .Rate.AutoHelp}}</p></div>
</div>
</form>
<div class="buttons">
<button type="submit" class="button is-primary" form="rate-limit">Save limit</button>
{{if .Rate.Auto}}<form method="post" action="{{.RecalcAction}}"><button type="submit" class="button">Recalculate now</button></form>{{end}}
{{if .Rate.Active}}<form method="post" action="{{.LimitAction}}" data-confirm="Remove the domain rate limit? Only the global level-1 limit will apply."><input type="hidden" name="clear" value="1"><button type="submit" class="button is-danger is-light">Remove limit</button></form>{{end}}
</div>
<p class="sp-muted mt-4">{{template "inline" .LimitNote}}</p>
{{template "body_close"}}
{{template "box_close"}}
{{if .CanDelete}}
{{template "box_open" .Delete}}
{{template "body_open"}}
<p class="mb-3">{{template "inline" .DeleteText}}</p>
{{template "action" .DeleteLink}}
{{template "body_close"}}
{{template "box_close"}}
{{end}}
</div>
</div>
{{end}}
@@ -0,0 +1,35 @@
{{/* Outbound domains (mockup out-domains.html): the domains this relay signs and
sends for, and — for the global role, who alone adds and deletes domains —
the form that adds one. A domain administrator sees the list alone. */}}
{{define "content"}}
{{template "page_head" .Head}}
{{if .Flash}}{{template "flash" .Flash}}{{end}}
{{if .CanManage}}
{{template "box_open" .Add}}
{{template "body_open"}}
<form class="sp-form" method="post" action="/outbound/domains">
<div class="field has-addons">
<div class="control is-expanded has-icons-left"><input class="input" name="name" type="text" value="{{.AddName}}" placeholder="example.com" autocomplete="off" autocapitalize="none" spellcheck="false" required><span class="icon is-left"><i class="ti ti-world"></i></span></div>
<div class="control"><button type="submit" class="button is-primary"><i class="ti ti-plus"></i>Add domain</button></div>
</div>
<p class="help">{{template "inline" .AddHelp}}</p>
</form>
{{template "body_close"}}
{{template "box_close"}}
{{end}}
{{template "box_open" .List}}
{{if .Rows}}
<div class="table-container"><table class="table is-fullwidth is-hoverable">
<thead><tr><th>Domain</th><th>DNS</th><th>Selector</th><th>Apps</th><th>{{.Days}} days</th><th></th></tr></thead>
<tbody>{{range .Rows}}
<tr><td><a href="{{.Href}}"><strong>{{.Name}}</strong></a></td><td>{{range $i, $t := .DNS}}{{if $i}} {{end}}{{template "tag" $t}}{{end}}</td><td class="sp-muted sp-mono">{{.Selector}}</td><td>{{.Apps}}</td><td class="sp-muted">{{.Activity}}</td><td class="sp-actions"><a href="{{.Href}}">Open</a>{{if .DeleteHref}}<a class="has-text-danger" href="{{.DeleteHref}}">Delete</a>{{end}}</td></tr>{{end}}
</tbody>
</table></div>
{{template "box_foot" .Legend}}
{{else}}
{{template "empty_state" .Empty}}
{{end}}
{{template "box_close"}}
{{end}}
+6 -77
View File
@@ -138,7 +138,7 @@ func TestNavMarksActivePage(t *testing.T) {
t.Fatalf("New: %v", err)
}
var buf bytes.Buffer
err = engine.Page("dashboard").ExecuteTemplate(&buf, "nav", map[string]any{
err = engine.Page("help").ExecuteTemplate(&buf, "nav", map[string]any{
"User": "admin",
"Active": "mail_queue",
"IsGlobal": true,
@@ -166,7 +166,7 @@ func TestNavLeadsWithStatusAndPointsDomainsAtItsOwnPath(t *testing.T) {
t.Fatalf("New: %v", err)
}
var buf bytes.Buffer
if err := engine.Page("dashboard").ExecuteTemplate(&buf, "nav", map[string]any{
if err := engine.Page("help").ExecuteTemplate(&buf, "nav", map[string]any{
"User": "admin",
"Active": "status",
"IsGlobal": true,
@@ -195,7 +195,7 @@ func TestNavShowsInboundWhenEnabled(t *testing.T) {
}
engine.SetInboundEnabled(true)
var buf bytes.Buffer
if err := engine.Page("dashboard").ExecuteTemplate(&buf, "nav", map[string]any{
if err := engine.Page("help").ExecuteTemplate(&buf, "nav", map[string]any{
"User": "admin",
"Active": "status",
"IsGlobal": true,
@@ -226,8 +226,8 @@ func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) {
}
wide := map[string]bool{
"deliveries": true, "delivery": true, "mail_queue": true,
"system_log": true, "domain_detail": true,
"inbound": true, "inbound_domain": true, "dmarc": true,
"system_log": true,
"inbound": true, "inbound_domain": true, "dmarc": true,
}
for name, page := range engine.Pages() {
if kitPages[name] {
@@ -247,58 +247,6 @@ func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) {
}
}
// The domain page pairs cards the same way Status does: three .split rows
// (DKIM+SPF|DMARC, connection|add-app, export|danger). DNS status, Applications
// and Domain settings are full-width; DNS status and Domain settings (and the
// application Edit panel) use .check-cols. Losing a row silently stacks again.
func TestDomainDetailPageHasPairedCards(t *testing.T) {
body, err := fs.ReadFile(assetsFS, "templates/domain_detail.html")
if err != nil {
t.Fatalf("read domain_detail: %v", err)
}
src := string(body)
if got := strings.Count(src, `class="split"`); got != 3 {
t.Errorf("domain detail has %d .split rows, want 3", got)
}
if !strings.Contains(src, `class="check-cols"`) {
t.Error("domain detail is missing the check-cols grid")
}
if !strings.Contains(src, `class="panel-toggle t-edit"`) {
t.Error("application Edit should be a single panel-toggle")
}
if strings.Contains(src, `panel-toggle t-mode`) || strings.Contains(src, `panel-toggle t-limit`) ||
strings.Contains(src, `panel-mode`) || strings.Contains(src, `panel-limit`) {
t.Error("application Edit mode and Rate limit should be one Edit button")
}
for _, id := range []string{
`id="dkim-spf"`, `id="dns-status"`, `id="dmarc"`,
`id="connection"`, `id="add-application"`, `id="applications"`,
`id="domain-settings"`, `id="export"`, `id="danger"`,
} {
if !strings.Contains(src, id) {
t.Errorf("domain detail is missing %s", id)
}
}
if strings.Contains(src, `id="rate-limit"`) {
t.Error("domain rate limit should live inside domain-settings, not its own card")
}
if strings.Contains(src, `id="d_ips"`) {
t.Error("domain rate limit must not ask for client IPs")
}
if !strings.Contains(src, "{{.L1Messages}}") && !strings.Contains(src, "{{$.L1Messages}}") {
t.Error("domain rate limit should show the L1 message count")
}
if !strings.Contains(src, "Level&nbsp;1 backstop") {
t.Error("domain rate limit should show a Level 1 backstop line")
}
if !strings.Contains(src, "Restrict to listed IPs") {
t.Error("application should offer client IP allow-list")
}
if strings.Contains(src, `id="spf-dmarc"`) {
t.Error("SPF should sit with DKIM, not with DMARC")
}
}
func TestSettingsPageDocumentsRateLimits(t *testing.T) {
out := renderSignedIn(t, "settings", settingsFixture())
for _, want := range []string{
@@ -325,8 +273,6 @@ func TestDrillDownPagesPlaceBackLinkAboveContent(t *testing.T) {
drillDown := map[string]bool{
"user_form.html": true,
"user_delete.html": true,
"domain_detail.html": true,
"domain_delete.html": true,
"inbound_domain.html": true,
"inbound_delete.html": true,
"dmarc_domain.html": true,
@@ -530,30 +476,13 @@ func TestOverviewLinksToItsHelpTopic(t *testing.T) {
}
}
func TestDomainDetailHasHelpOnCards(t *testing.T) {
body, err := fs.ReadFile(assetsFS, "templates/domain_detail.html")
if err != nil {
t.Fatalf("read domain_detail: %v", err)
}
src := string(body)
for _, want := range []string{
`"ID" "dns"`, `"ID" "records"`, `"ID" "dmarc"`, `"ID" "connection"`,
`"ID" "apps"`, `"ID" "domain-settings"`, `"ID" "export"`,
`card-head`,
} {
if !strings.Contains(src, want) {
t.Errorf("domain_detail missing %q", want)
}
}
}
func TestNavIncludesHelp(t *testing.T) {
engine, err := New("test")
if err != nil {
t.Fatalf("New: %v", err)
}
var buf bytes.Buffer
if err := engine.Page("dashboard").ExecuteTemplate(&buf, "nav", map[string]any{
if err := engine.Page("help").ExecuteTemplate(&buf, "nav", map[string]any{
"User": "admin", "Active": "domains", "IsGlobal": true,
}); err != nil {
t.Fatalf("execute nav: %v", err)
+35 -25
View File
@@ -36,31 +36,34 @@ type Engine struct {
// encryption fields on the two secret downloads) list that partial the same
// way.
var pageFiles = map[string][]string{
"setup": {"templates/setup.html"},
"login": {"templates/login.html"},
"dashboard": {"templates/dashboard.html"},
"overview": {"templates/overview.html", "templates/overview_cards.html"},
"health": {"templates/health.html", "templates/health_body.html"},
"account": {"templates/account.html"},
"settings": {"templates/settings.html"},
"users": {"templates/users.html"},
"user_form": {"templates/user_form.html"},
"user_delete": {"templates/user_delete.html"},
"backup": {"templates/backup.html", "templates/encrypt_fields.html"},
"domain_detail": {"templates/domain_detail.html", "templates/encrypt_fields.html"},
"domain_delete": {"templates/domain_delete.html"},
"inbound": {"templates/inbound.html"},
"inbound_domain": {"templates/inbound_domain.html"},
"inbound_delete": {"templates/inbound_delete.html"},
"dmarc": {"templates/dmarc.html"},
"dmarc_domain": {"templates/dmarc_domain.html"},
"dmarc_report": {"templates/dmarc_report.html"},
"deliveries": {"templates/deliveries.html", "templates/deliveries_rows.html"},
"delivery": {"templates/delivery.html"},
"mail_queue": {"templates/mail_queue.html", "templates/mail_queue_body.html"},
"system_log": {"templates/system_log.html", "templates/system_log_body.html"},
"help": {"templates/help.html"},
"components": {"templates/kit.html"},
"setup": {"templates/setup.html"},
"login": {"templates/login.html"},
"overview": {"templates/overview.html", "templates/overview_cards.html"},
"health": {"templates/health.html", "templates/health_body.html"},
"account": {"templates/account.html"},
"settings": {"templates/settings.html"},
"users": {"templates/users.html"},
"user_form": {"templates/user_form.html"},
"user_delete": {"templates/user_delete.html"},
"backup": {"templates/backup.html", "templates/encrypt_fields.html"},
"out-domains": {"templates/out_domains.html"},
"out-domain": {"templates/out_domain.html"},
"out-domain-settings": {"templates/out_domain_settings.html"},
"out-app": {"templates/out_app.html"},
"out-app-created": {"templates/out_app_created.html"},
"out-domain-delete": {"templates/out_domain_delete.html"},
"inbound": {"templates/inbound.html"},
"inbound_domain": {"templates/inbound_domain.html"},
"inbound_delete": {"templates/inbound_delete.html"},
"dmarc": {"templates/dmarc.html"},
"dmarc_domain": {"templates/dmarc_domain.html"},
"dmarc_report": {"templates/dmarc_report.html"},
"deliveries": {"templates/deliveries.html", "templates/deliveries_rows.html"},
"delivery": {"templates/delivery.html"},
"mail_queue": {"templates/mail_queue.html", "templates/mail_queue_body.html"},
"system_log": {"templates/system_log.html", "templates/system_log_body.html"},
"help": {"templates/help.html"},
"components": {"templates/kit.html"},
}
// kitPages are the pages built from the component kit: they are rendered by
@@ -77,6 +80,13 @@ var kitPages = map[string]bool{
"health": true,
"account": true,
"settings": true,
"out-domains": true,
"out-domain": true,
"out-domain-settings": true,
"out-app": true,
"out-app-created": true,
"out-domain-delete": true,
}
// fragmentFiles maps a fragment name (also its {{define}} block name) to its
+1 -3
View File
@@ -195,9 +195,7 @@ func (s *Server) muxes() (public, authed *http.ServeMux) {
authed.HandleFunc("POST /outbound/domains/{id}/dns-recheck", h.HandleDomainDNSRecheck)
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("GET /outbound/domains/{id}/settings", h.HandleDomainSettings)
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)