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 }