release: 1.6.0
test / test (push) Waiting to run

Add 30-day send statistics and auto level-2 rate limits on the domain page. Close Unreleased; pin compose and docs to 1.6.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-18 22:26:51 +03:00
parent 8538b8d5f6
commit c1ec4fbd79
27 changed files with 1092 additions and 140 deletions
+89 -9
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"strconv"
"strings"
"time"
"github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/domain"
@@ -43,10 +44,22 @@ type detailView struct {
// template fields (Login, AddressMode, Addresses, ID) resolve unchanged.
type appRateLimitView struct {
store.Application
HasLimit bool // an active limit is configured
IPsText string // allowed IPs, newline-joined for the textarea
MaxText string // message ceiling, blank when unset
WindowVal string // window seconds, defaulted when unset
HasLimit bool
IPsText string
MaxText string
WindowVal string
Mode string
AutoMultiplier string
AutoUpdated string
IsAuto bool
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)
@@ -77,6 +90,7 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
return
}
appViews := make([]appRateLimitView, 0, len(apps))
retention := h.sendLogRetentionDays()
for _, a := range apps {
rl, ok, err := h.apps.RateLimit(a.ID)
if err != nil {
@@ -84,12 +98,31 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
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(),
IPsText: strings.Join(rl.AllowedIPs, "\n"),
MaxText: intOrBlank(rl.MaxMessages),
WindowVal: windowOrDefault(rl.WindowSeconds),
Application: a,
HasLimit: ok && rl.Active(),
IPsText: strings.Join(rl.AllowedIPs, "\n"),
MaxText: intOrBlank(rl.MaxMessages),
WindowVal: windowOrDefault(rl.WindowSeconds),
Mode: mode,
AutoMultiplier: formatMultiplier(mult),
IsAuto: ok && rl.IsAuto(),
AutoUpdated: formatAutoUpdated(rl.AutoUpdatedAt),
Stats: formatSendStats(appStats),
})
}
@@ -99,6 +132,22 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
domainStats, err := h.store.DomainSendStats(d.Name, retention, d.CreatedAt)
if err != nil {
logf("panel: domain %d: send stats: %v", d.ID, err)
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
}
}
// 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
@@ -168,8 +217,18 @@ func (h *Handlers) renderDomainDetail(w http.ResponseWriter, r *http.Request, st
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)
}
@@ -232,6 +291,25 @@ 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)
}
func formatAutoUpdated(t time.Time) string {
if t.IsZero() {
return ""
}
return t.UTC().Format("2006-01-02 15:04 UTC")
}
// detailFlash maps a fixed redirect flag to a fixed message, so status text
// after a redirect is never attacker-influenced.
func detailFlash(r *http.Request) string {
@@ -242,6 +320,8 @@ func detailFlash(r *http.Request) string {
return "Application address mode updated."
case r.URL.Query().Get("ratelimit") != "":
return "Rate limit updated."
case r.URL.Query().Get("recalculated") != "":
return "Auto rate limit recalculated."
case r.URL.Query().Get("dmarc") != "":
return "DMARC report settings updated."
case r.URL.Query().Get("imported") != "":
+143 -20
View File
@@ -13,10 +13,12 @@ import (
const defaultRateLimitWindowSeconds = 3600
type rateLimitInput struct {
clear bool
ips []string
maxMessages int
windowSeconds int
clear bool
mode string
ips []string
maxMessages int
windowSeconds int
autoMultiplier float64
}
func (h *Handlers) l1Messages() int {
@@ -33,6 +35,32 @@ func (h *Handlers) l1Window() int {
return defaultRateLimitWindowSeconds
}
func parseAutoMultiplier(raw string) (float64, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return store.DefaultAutoMultiplier, nil
}
v, err := strconv.ParseFloat(raw, 64)
if err != nil {
return 0, fmt.Errorf("enter a valid multiplier (%.1f%.1f)", store.MinAutoMultiplier, store.MaxAutoMultiplier)
}
if v < store.MinAutoMultiplier || v > store.MaxAutoMultiplier {
return 0, fmt.Errorf("multiplier must be between %.1f and %.1f", store.MinAutoMultiplier, store.MaxAutoMultiplier)
}
return v, nil
}
func parseRateLimitMode(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return store.RateLimitModeManual, nil
}
if raw != store.RateLimitModeManual && raw != store.RateLimitModeAuto {
return "", fmt.Errorf("choose manual or auto mode")
}
return raw, nil
}
func parseDomainRateLimitForm(r *http.Request, l1Max int) (rateLimitInput, error) {
if err := r.ParseForm(); err != nil {
return rateLimitInput{}, fmt.Errorf("invalid form submission")
@@ -40,6 +68,18 @@ func parseDomainRateLimitForm(r *http.Request, l1Max int) (rateLimitInput, error
if r.PostFormValue("clear") != "" {
return rateLimitInput{clear: true}, nil
}
mode, err := parseRateLimitMode(r.PostFormValue("mode"))
if err != nil {
return rateLimitInput{}, err
}
if mode == store.RateLimitModeAuto {
mult, err := parseAutoMultiplier(r.PostFormValue("auto_multiplier"))
if err != nil {
return rateLimitInput{}, err
}
return rateLimitInput{mode: mode, autoMultiplier: mult}, nil
}
rawMax := strings.TrimSpace(r.PostFormValue("max_messages"))
if rawMax == "" {
return rateLimitInput{clear: true}, nil
@@ -55,7 +95,7 @@ func parseDomainRateLimitForm(r *http.Request, l1Max int) (rateLimitInput, error
if err != nil || windowSeconds <= 0 {
return rateLimitInput{}, fmt.Errorf("enter a time window greater than zero seconds")
}
return rateLimitInput{maxMessages: maxMessages, windowSeconds: windowSeconds}, nil
return rateLimitInput{mode: store.RateLimitModeManual, maxMessages: maxMessages, windowSeconds: windowSeconds}, nil
}
func parseAppRateLimitForm(r *http.Request, l1Max, domainMax int, domainActive bool) (rateLimitInput, error) {
@@ -65,10 +105,11 @@ func parseAppRateLimitForm(r *http.Request, l1Max, domainMax int, domainActive b
if r.PostFormValue("clear") != "" {
return rateLimitInput{clear: true}, nil
}
rawMax := strings.TrimSpace(r.PostFormValue("max_messages"))
if rawMax == "" {
return rateLimitInput{clear: true}, nil
mode, err := parseRateLimitMode(r.PostFormValue("mode"))
if err != nil {
return rateLimitInput{}, err
}
ips, err := parseIPList(r.PostFormValue("allowed_ips"))
if err != nil {
return rateLimitInput{}, err
@@ -76,6 +117,19 @@ func parseAppRateLimitForm(r *http.Request, l1Max, domainMax int, domainActive b
if len(ips) == 0 {
return rateLimitInput{}, fmt.Errorf("enter at least one trusted client IP for an application override")
}
if mode == store.RateLimitModeAuto {
mult, err := parseAutoMultiplier(r.PostFormValue("auto_multiplier"))
if err != nil {
return rateLimitInput{}, err
}
return rateLimitInput{mode: mode, ips: ips, autoMultiplier: mult}, nil
}
rawMax := strings.TrimSpace(r.PostFormValue("max_messages"))
if rawMax == "" {
return rateLimitInput{clear: true}, nil
}
maxMessages, err := parsePositiveInt(rawMax, 0)
if err != nil || maxMessages <= 0 {
return rateLimitInput{}, fmt.Errorf("enter a message limit greater than zero")
@@ -90,7 +144,7 @@ func parseAppRateLimitForm(r *http.Request, l1Max, domainMax int, domainActive b
if err != nil || windowSeconds <= 0 {
return rateLimitInput{}, fmt.Errorf("enter a time window greater than zero seconds")
}
return rateLimitInput{ips: ips, maxMessages: maxMessages, windowSeconds: windowSeconds}, nil
return rateLimitInput{mode: store.RateLimitModeManual, ips: ips, maxMessages: maxMessages, windowSeconds: windowSeconds}, nil
}
func parseIPList(raw string) ([]string, error) {
@@ -134,7 +188,7 @@ func (h *Handlers) HandleDomainRateLimit(w http.ResponseWriter, r *http.Request)
})
return
}
if err := h.applyRateLimit(in, h.domains.SaveRateLimit, h.domains.ClearRateLimit, d.ID); err != nil {
if err := h.applyDomainRateLimit(in, d.ID); err != nil {
logf("panel: domain %d: save rate limit: %v", d.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
@@ -167,7 +221,7 @@ func (h *Handlers) HandleAppRateLimit(w http.ResponseWriter, r *http.Request) {
})
return
}
if err := h.applyRateLimit(in, h.apps.SaveRateLimit, h.apps.ClearRateLimit, a.ID); err != nil {
if err := h.applyAppRateLimit(in, a.ID); err != nil {
logf("panel: application %d: save rate limit: %v", a.ID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
@@ -175,14 +229,83 @@ func (h *Handlers) HandleAppRateLimit(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, fmt.Sprintf("/domains/%d?ratelimit=1", a.DomainID), http.StatusSeeOther)
}
func (h *Handlers) applyRateLimit(
in rateLimitInput,
save func(id int64, ips []string, maxMessages, windowSeconds int) error,
clear func(id int64) error,
id int64,
) error {
if in.clear {
return clear(id)
func (h *Handlers) HandleDomainRateLimitRecalc(w http.ResponseWriter, r *http.Request) {
d, ok := h.lookupDomain(w, r)
if !ok {
return
}
return save(id, in.ips, in.maxMessages, in.windowSeconds)
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(),
})
return
}
http.Redirect(w, r, fmt.Sprintf("/domains/%d?recalculated=1", d.ID), http.StatusSeeOther)
}
func (h *Handlers) HandleAppRateLimitRecalc(w http.ResponseWriter, r *http.Request) {
a, ok := h.lookupApplication(w, r)
if !ok {
return
}
if err := h.recalcRateLimit(store.RateLimitScopeApp, a.ID); err != nil {
d, _ := h.domains.Get(a.DomainID)
h.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{
FormMode: store.AddressModeWildcard,
RateLimitErr: fmt.Sprintf("%s: %s", a.Login, err.Error()),
})
return
}
http.Redirect(w, r, fmt.Sprintf("/domains/%d?recalculated=1", a.DomainID), http.StatusSeeOther)
}
func (h *Handlers) recalcRateLimit(scope string, refID int64) error {
return h.store.RecalcAutoRateLimit(scope, refID, h.sendLogRetentionDays(), h.l1Messages(), h.l1Window())
}
func (h *Handlers) applyDomainRateLimit(in rateLimitInput, domainID int64) error {
if in.clear {
return h.domains.ClearRateLimit(domainID)
}
rl := store.RateLimit{
Scope: store.RateLimitScopeDomain,
RefID: domainID,
Mode: in.mode,
MaxMessages: in.maxMessages,
WindowSeconds: in.windowSeconds,
AutoMultiplier: in.autoMultiplier,
}
if in.mode == store.RateLimitModeAuto {
rl.WindowSeconds = h.l1Window()
if err := h.domains.SaveRateLimit(domainID, rl); err != nil {
return err
}
return h.recalcRateLimit(store.RateLimitScopeDomain, domainID)
}
return h.domains.SaveRateLimit(domainID, rl)
}
func (h *Handlers) applyAppRateLimit(in rateLimitInput, appID int64) error {
if in.clear {
return h.apps.ClearRateLimit(appID)
}
rl := store.RateLimit{
Scope: store.RateLimitScopeApp,
RefID: appID,
AllowedIPs: in.ips,
Mode: in.mode,
MaxMessages: in.maxMessages,
WindowSeconds: in.windowSeconds,
AutoMultiplier: in.autoMultiplier,
}
if in.mode == store.RateLimitModeAuto {
rl.WindowSeconds = h.l1Window()
if err := h.apps.SaveRateLimit(appID, rl); err != nil {
return err
}
return h.recalcRateLimit(store.RateLimitScopeApp, appID)
}
return h.apps.SaveRateLimit(appID, rl)
}
@@ -17,6 +17,7 @@ func TestParseDomainRateLimitForm(t *testing.T) {
}
in, err := parseDomainRateLimitForm(form(url.Values{
"mode": {"manual"},
"max_messages": {"50"},
"window_seconds": {"3600"},
}), 100)
@@ -30,6 +31,7 @@ func TestParseDomainRateLimitForm(t *testing.T) {
}
_, err = parseDomainRateLimitForm(form(url.Values{
"mode": {"manual"},
"max_messages": {"150"},
"window_seconds": {"3600"},
}), 100)
@@ -47,6 +49,7 @@ func TestParseAppRateLimitForm(t *testing.T) {
}
in, err := parseAppRateLimitForm(form(url.Values{
"mode": {"manual"},
"allowed_ips": {"203.0.113.10"},
"max_messages": {"80"},
"window_seconds": {"3600"},
@@ -56,6 +59,7 @@ func TestParseAppRateLimitForm(t *testing.T) {
}
_, err = parseAppRateLimitForm(form(url.Values{
"mode": {"manual"},
"max_messages": {"80"},
"window_seconds": {"3600"},
}), 100, 40, true)
@@ -64,6 +68,7 @@ func TestParseAppRateLimitForm(t *testing.T) {
}
_, err = parseAppRateLimitForm(form(url.Values{
"mode": {"manual"},
"allowed_ips": {"203.0.113.10"},
"max_messages": {"40"},
"window_seconds": {"3600"},
@@ -73,6 +78,7 @@ func TestParseAppRateLimitForm(t *testing.T) {
}
_, err = parseAppRateLimitForm(form(url.Values{
"mode": {"manual"},
"allowed_ips": {"203.0.113.10"},
"max_messages": {"150"},
"window_seconds": {"3600"},
@@ -83,6 +89,7 @@ func TestParseAppRateLimitForm(t *testing.T) {
// No domain limit: any app ceiling ≤ L1 is fine.
in, err = parseAppRateLimitForm(form(url.Values{
"mode": {"manual"},
"allowed_ips": {"203.0.113.10"},
"max_messages": {"50"},
"window_seconds": {"3600"},
@@ -91,3 +98,26 @@ func TestParseAppRateLimitForm(t *testing.T) {
t.Fatalf("app without domain = %+v err=%v", in, err)
}
}
func TestParseDomainRateLimitFormAuto(t *testing.T) {
t.Parallel()
form := func(vals url.Values) *http.Request {
r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(vals.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return r
}
in, err := parseDomainRateLimitForm(form(url.Values{
"mode": {"auto"},
"auto_multiplier": {"2.5"},
}), 100)
if err != nil || in.mode != "auto" || in.autoMultiplier != 2.5 {
t.Fatalf("auto domain = %+v err=%v", in, err)
}
_, err = parseDomainRateLimitForm(form(url.Values{
"mode": {"auto"},
"auto_multiplier": {"10"},
}), 100)
if err == nil {
t.Fatal("multiplier out of range should fail")
}
}
+10
View File
@@ -61,6 +61,16 @@
target: "[data-custom-address]",
visible: function (el) { return el.value === el.dataset.customMode; }
},
{
match: "select[data-ratelimit-mode]",
target: "[data-manual-fields]",
visible: function (el) { return el.value === "manual"; }
},
{
match: "select[data-ratelimit-mode]",
target: "[data-auto-fields]",
visible: function (el) { return el.value === "auto"; }
},
{
match: "select[data-global-role]",
target: "[data-domain-pick]",
+86 -22
View File
@@ -236,6 +236,28 @@
</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">
<h2>Applications</h2>
<p class="muted">SASL logins for this domain — wildcard (*@domain) or a fixed
@@ -250,6 +272,8 @@
{{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. -->
@@ -293,22 +317,41 @@
(≤ level&nbsp;1) and skip the domain check; everyone else uses
the domain level-2 limit{{if $.DomainHasRL}} ({{$.DomainRLMaxNum}}){{end}}
if set, otherwise level&nbsp;1.</p>
<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}}">
<label for="mode-{{.ID}}">Limit mode</label>
<select id="mode-{{.ID}}" name="mode" data-ratelimit-mode>
<option value="manual" {{if not .IsAuto}}selected{{end}}>Manual</option>
<option value="auto" {{if .IsAuto}}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>
</form>
<!-- Saving and removing the limit are two posts; the Save
button is bound by form= id so both buttons share a row. -->
<div class="panel-buttons">
<button type="submit" form="rl-{{.ID}}">Save limit</button>
{{if .IsAuto}}
<form class="inline" method="post" action="/applications/{{.ID}}/ratelimit/recalc">
<button type="submit">Recalculate now</button>
</form>
{{end}}
{{if .HasLimit}}
<form class="inline" method="post" action="/applications/{{.ID}}/ratelimit"
data-confirm="Remove the rate limit for {{.Login}}? The domain limit (or level 1) will apply.">
@@ -365,23 +408,44 @@
<p class="muted">Level&nbsp;1 backstop: {{.L1Messages}} messages / {{.L1Window}}s — <a href="/settings#rate-limits">Settings</a>.</p>
<div class="check-col-fields">
<form id="rl-domain" method="post" action="/domains/{{.Domain.ID}}/ratelimit">
<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}}">
<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>
<p class="muted">Applies to every client IP on this domain. Leave the
message limit empty to use level&nbsp;1 only.</p>
</form>
</div>
<div class="check-col-actions">
<button type="submit" form="rl-domain">Save limit</button>
{{if .DomainRLAuto}}
<form class="inline" method="post" action="/domains/{{.Domain.ID}}/ratelimit/recalc">
<button type="submit">Recalculate now</button>
</form>
{{end}}
{{if .DomainHasRL}}
<form class="inline" method="post" action="/domains/{{.Domain.ID}}/ratelimit"
data-confirm="Remove the domain rate limit? Only the global level-1 limit will apply.">
+2
View File
@@ -167,11 +167,13 @@ func (s *Server) Handler() http.Handler {
authed.HandleFunc("POST /domains/{id}/delete", h.HandleDeleteDomain)
authed.HandleFunc("POST /domains/{id}/applications", h.HandleAddApplication)
authed.HandleFunc("POST /domains/{id}/ratelimit", h.HandleDomainRateLimit)
authed.HandleFunc("POST /domains/{id}/ratelimit/recalc", h.HandleDomainRateLimitRecalc)
authed.HandleFunc("POST /domains/{id}/dmarc", h.HandleDomainDMARC)
authed.HandleFunc("POST /domains/{id}/export", h.HandleExportDomain)
authed.HandleFunc("POST /applications/{aid}/mode", h.HandleUpdateAppMode)
authed.HandleFunc("POST /applications/{aid}/password", h.HandleRegenPassword)
authed.HandleFunc("POST /applications/{aid}/ratelimit", h.HandleAppRateLimit)
authed.HandleFunc("POST /applications/{aid}/ratelimit/recalc", h.HandleAppRateLimitRecalc)
authed.HandleFunc("POST /applications/{aid}/delete", h.HandleDeleteApplication)
authed.HandleFunc("POST /reload", h.HandleReload)