package api import ( "context" "encoding/json" "io" "net/http" "strings" "sync" "time" "gitea.dooplex.hu/admin/felhom-hub/internal/mailrelay" ) // maxMailBytes bounds a forwarded message (raw_mime is base64 in the JSON envelope, so the // JSON body is ~4/3 of this; 12 MiB JSON comfortably covers a 10 MiB message). const maxMailBytes = 12 << 20 // mailRateLimiter is a per-key token bucket (key = customerID, or the envelope From when a // global key is used). Containment: one box can't drain the shared Resend quota for the // whole fleet. In-memory (lost on restart, acceptable — same posture as the dispatcher // cooldowns). type mailRateLimiter struct { mu sync.Mutex perMinute int buckets map[string]*tokenBucket now func() time.Time // injectable for tests } type tokenBucket struct { tokens float64 last time.Time } func newMailRateLimiter(perMinute int) *mailRateLimiter { if perMinute <= 0 { perMinute = 30 } return &mailRateLimiter{ perMinute: perMinute, buckets: make(map[string]*tokenBucket), now: time.Now, } } // allow consumes one token for key, refilling at perMinute/60 tokens per second up to a // burst capacity of perMinute. Returns false when the bucket is empty. func (rl *mailRateLimiter) allow(key string) bool { rl.mu.Lock() defer rl.mu.Unlock() now := rl.now() cap := float64(rl.perMinute) b, ok := rl.buckets[key] if !ok { rl.buckets[key] = &tokenBucket{tokens: cap - 1, last: now} return true } // Refill based on elapsed time. elapsed := now.Sub(b.last).Seconds() b.tokens += elapsed * (cap / 60.0) if b.tokens > cap { b.tokens = cap } b.last = now if b.tokens < 1 { return false } b.tokens-- return true } // SetMailRelay wires the app-email passthrough: the Resend SMTP sender, the per-customer // rate limit (messages/min), and the From-header domain allowlist. nil sender = the // /api/v1/mail endpoint returns 503 (app mail not configured). func (h *Handler) SetMailRelay(sender mailrelay.Sender, perMinute int, allowedDomains []string) { h.mailSender = sender h.mailLimiter = newMailRateLimiter(perMinute) h.mailFromAllow = make(map[string]bool) if len(allowedDomains) == 0 { allowedDomains = []string{"felhom.eu"} } for _, d := range allowedDomains { d = strings.ToLower(strings.TrimSpace(d)) if d != "" { h.mailFromAllow[d] = true } } } // handleMail accepts a raw message from a customer box and passes it through to Resend // over SMTP (raw, unchanged). Auth → From-policy → per-box rate limit → passthrough. func (h *Handler) handleMail(w http.ResponseWriter, r *http.Request) { custID, _, ok := h.checkAuthCustomer(r) if !ok { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } if h.mailSender == nil { http.Error(w, "App mail not configured", http.StatusServiceUnavailable) return } body, err := io.ReadAll(io.LimitReader(r.Body, maxMailBytes+1)) if err != nil { http.Error(w, "Bad request", http.StatusBadRequest) return } if len(body) > maxMailBytes { http.Error(w, "Payload too large", http.StatusRequestEntityTooLarge) return } var req struct { RawMIME []byte `json:"raw_mime"` MailFrom string `json:"mail_from"` RcptTo []string `json:"rcpt_to"` } if err := json.Unmarshal(body, &req); err != nil || len(req.RawMIME) == 0 || len(req.RcptTo) == 0 { http.Error(w, "Invalid payload: raw_mime and rcpt_to required", http.StatusBadRequest) return } // From-policy (backstop — the shim already enforced this; Resend is the final backstop). // Reject 4xx if the From-header domain is not allowlisted. dom, err := mailrelay.FromDomain(req.RawMIME) if err != nil { h.logger.Printf("[WARN] /api/v1/mail: unparseable From from %s: %v", custID, err) http.Error(w, "Invalid message: unparseable From header", http.StatusBadRequest) return } if !h.mailFromAllow[dom] { h.logger.Printf("[WARN] /api/v1/mail: rejected From domain %q from %s", dom, custID) http.Error(w, "Forbidden: From domain not permitted", http.StatusForbidden) return } // Per-box rate limit (key = customer; fall back to envelope From for a global-key caller). key := custID if key == "" { key = strings.ToLower(req.MailFrom) } if h.mailLimiter != nil && !h.mailLimiter.allow(key) { h.logger.Printf("[WARN] /api/v1/mail: rate limit hit for %q", key) http.Error(w, "Too Many Requests", http.StatusTooManyRequests) return } ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() if err := h.mailSender.Send(ctx, req.RawMIME, req.MailFrom, req.RcptTo); err != nil { // A refusal/transport failure → 502; the box's shim surfaces it to the app (a 5xx // maps to a permanent SMTP error). The upstream reason is logged, not echoed in full. h.logger.Printf("[ERROR] /api/v1/mail: relay to Resend failed for %s (from=%s): %v", custID, req.MailFrom, err) http.Error(w, "Upstream mail delivery failed", http.StatusBadGateway) return } h.logger.Printf("[INFO] /api/v1/mail: relayed for %s (from=%s rcpts=%d bytes=%d)", custID, req.MailFrom, len(req.RcptTo), len(req.RawMIME)) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) w.Write([]byte(`{"status":"ok"}`)) }