fa3c4f2657
Raw-MIME passthrough (STARTTLS, AUTH LOGIN) — separate from the notify HTTP-API alert path (which drops inline CID images). Per-customer token-bucket rate limit, From-header allowlist backstop. Resend key stays hub-side. No new external dep. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
162 lines
5.1 KiB
Go
162 lines
5.1 KiB
Go
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"}`))
|
|
}
|