96 lines
2.5 KiB
Go
96 lines
2.5 KiB
Go
package mailer
|
|
|
|
import (
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Limits for verification emails (defaults match docs/installer-registration-api.md).
|
|
type Limits struct {
|
|
Cooldown time.Duration // min gap between emails to one address (resend)
|
|
PerEmailHour int // emails per address per hour
|
|
PerIPHour int // emails triggered per client IP per hour
|
|
GlobalMinute int // all emails per minute (protects the Gmail account: ~500/day cap)
|
|
GlobalDay int
|
|
}
|
|
|
|
// DefaultLimits keeps well under Gmail's sending limits.
|
|
var DefaultLimits = Limits{Cooldown: 60 * time.Second, PerEmailHour: 5, PerIPHour: 10, GlobalMinute: 20, GlobalDay: 400}
|
|
|
|
// Limiter is an in-memory sliding-window limiter (single instance; use a shared
|
|
// store such as Redis when running several API replicas).
|
|
type Limiter struct {
|
|
L Limits
|
|
Now func() time.Time
|
|
mu sync.Mutex
|
|
ev map[string][]time.Time
|
|
}
|
|
|
|
func NewLimiter(l Limits) *Limiter {
|
|
return &Limiter{L: l, Now: time.Now, ev: map[string][]time.Time{}}
|
|
}
|
|
|
|
// LimitError carries a retry hint.
|
|
type LimitError struct {
|
|
Reason string
|
|
RetryAfter time.Duration
|
|
}
|
|
|
|
func (e *LimitError) Error() string {
|
|
return fmt.Sprintf("rate limited (%s); retry in %ds", e.Reason, int(e.RetryAfter.Seconds()+0.999))
|
|
}
|
|
|
|
func (l *Limiter) prune(k string, win time.Duration, now time.Time) []time.Time {
|
|
v := l.ev[k]
|
|
i := 0
|
|
for i < len(v) && now.Sub(v[i]) >= win {
|
|
i++
|
|
}
|
|
v = v[i:]
|
|
l.ev[k] = v
|
|
return v
|
|
}
|
|
|
|
// Allow checks every limit for (email, ip) and records the event only if all pass.
|
|
func (l *Limiter) Allow(email, ip string) error {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
now := l.Now()
|
|
type rule struct {
|
|
key string
|
|
win time.Duration
|
|
max int
|
|
reason string
|
|
}
|
|
rules := []rule{
|
|
{"e:" + email, l.L.Cooldown, 1, "resend cooldown"},
|
|
{"e:" + email, time.Hour, l.L.PerEmailHour, "too many codes for this email"},
|
|
{"i:" + ip, time.Hour, l.L.PerIPHour, "too many requests from this network"},
|
|
{"g", time.Minute, l.L.GlobalMinute, "service busy"},
|
|
{"g", 24 * time.Hour, l.L.GlobalDay, "daily email limit reached"},
|
|
}
|
|
for _, r := range rules {
|
|
if r.max <= 0 || r.win <= 0 {
|
|
continue
|
|
}
|
|
v := l.prune(r.key, 24*time.Hour, now)
|
|
n := 0
|
|
var oldest time.Time
|
|
for _, t := range v {
|
|
if now.Sub(t) < r.win {
|
|
if n == 0 {
|
|
oldest = t
|
|
}
|
|
n++
|
|
}
|
|
}
|
|
if n >= r.max {
|
|
return &LimitError{Reason: r.reason, RetryAfter: r.win - now.Sub(oldest)}
|
|
}
|
|
}
|
|
for _, k := range []string{"e:" + email, "i:" + ip, "g"} {
|
|
l.ev[k] = append(l.ev[k], now)
|
|
}
|
|
return nil
|
|
}
|