PrintToVeraeTimeStamp/internal/mailer/ratelimit.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
}